Save the admin price grid in one request
Java · Spring Boot · intermediate · modification
Adds the bulk save behind the admin price grid, so a merchandiser can change many prices in one click instead of editing products one by one. `BulkPriceController` validates the envelope and hands the rows to `BulkPriceService.apply`, which runs in one transaction. A first pass skips the grid's blank rows and rejects repeated SKUs and bad prices with the row number the admin sees. One `findBySkuIn` query then loads every product the request names, so an unknown SKU is rejected before anything changes. The last pass sets the prices and Hibernate flushes them on commit. Row errors come back as a `ProblemDetail` with the row number in `detail`.
Shop backend on Spring Boot 3.3 (Java 21), Spring Data JPA (Hibernate 6) on PostgreSQL. Existing and unchanged: the `Product` entity (`id`; `sku`, unique, non-null, `varchar(32)`; `name`; `price`, `numeric(7,2) NOT NULL`) with `setPrice`, and `ProductRepository extends JpaRepository<Product, Long>` with `findBySku`. The existing `SecurityConfig` restricts `/api/admin/**` to `ROLE_ADMIN` and handles CSRF for the admin console. Jackson binds `price` to `BigDecimal` and accepts a JSON number or a numeric string. Bean Validation failures on a `@Valid` request body are answered with 400 by Spring MVC's default handling. Two admins saving prices at the same moment is last-write-wins by product decision; the grid reloads after every save.
Requirements
- `POST /api/admin/prices/bulk` saves the admin console's price grid. The body is `{"rows": [{"sku": ..., "price": ...}, ...]}` with at most 500 rows, in the order the grid shows them. Each row sets the price of the product with that SKU to the price on the same row. The response is `{"updated": n}`, the number of products whose row was applied.
- The grid also sends its blank rows. A row whose `sku` is null, empty or only whitespace (as Java's `String.isBlank` defines it) is skipped, whatever its `price` holds (null for a row that was never filled in, the old value for a row whose SKU the admin cleared). Blank rows can appear anywhere in the list. A request with no rows, or only blank ones, changes nothing and returns `{"updated": 0}`.
- For every other row the SKU is matched exactly after stripping leading and trailing whitespace, and the price must be between 0.01 and 99999.99 with at most two decimal places; it is stored with two decimals.
- The save is all or nothing. If any non-blank row has a missing or invalid price, an unknown SKU, or a SKU an earlier row already set, the response is 400 with a message naming the row number (1-based, counting blank rows, as the grid numbers them) and no price changes. A missing `rows` field or a null row is also a 400.
Files touched
- src/main/java/com/acme/shop/catalog/ProductRepository.java
- src/main/java/com/acme/shop/catalog/BulkPriceRequest.java
- src/main/java/com/acme/shop/catalog/BulkPriceService.java
- src/main/java/com/acme/shop/catalog/BulkPriceController.java
--- src/main/java/com/acme/shop/catalog/ProductRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.Collection;
+import java.util.List;
import java.util.Optional;
Optional<Product> findBySku(String sku);
+
+ List<Product> findBySkuIn(Collection<String> skus);
}
--- src/main/java/com/acme/shop/catalog/BulkPriceRequest.java
+package com.acme.shop.catalog;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.math.BigDecimal;
+import java.util.List;
+
+public record BulkPriceRequest(@NotNull @Size(max = 500) List<@NotNull Row> rows) {
+
+ public record Row(String sku, BigDecimal price) {
+ }
+}
+
--- src/main/java/com/acme/shop/catalog/BulkPriceService.java
+package com.acme.shop.catalog;
+