Book packed orders with the carrier and mark them shipped
Java · Spring Boot · advanced · modification
Adds `ShipmentWorker`, a scheduled job that picks up packed orders, books each one with the carrier and stamps it shipped with the tracking code and time. The carrier call stays outside any database transaction so a slow booking never holds a connection or a row lock, and a `CarrierException` is logged and leaves that order packed for the next run while the rest of the batch carries on. `OrderRepository` gets the query for packed orders, oldest first.
Fulfilment service on Spring Boot 3.3 (Java 21), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16 at the default READ COMMITTED isolation; scheduling is enabled with the default single-threaded scheduler, and a `Clock` bean exists. Existing and unchanged: `Order`, a JPA entity mapped to `orders` with `id`, `reference`, `customerId`, `shippingAddress`, `status` (`OrderStatus`: `PLACED`, `PACKED`, `SHIPPED`, `CANCELLED`), `packedAt` (set on every packed order), `trackingCode` and `shippedAt`; it has no `@Version` column, no entity listeners and no `@DynamicUpdate`. `Order.markShipped(trackingCode, shippedAt)` sets the status to `SHIPPED` and the two fields, and throws `IllegalStateException` unless the order is `PACKED`. The address endpoint (existing) calls `OrderRepository.updateAddress` and answers 200 when it updated one row, otherwise 404 or 409. `CarrierClient` (existing) sets connect and read timeouts and wraps every failure in `CarrierException`; both live in `com.acme.fulfilment.shipping`, and `CarrierException` is unchecked. No transaction is open around the scheduled method, and the service has no other scheduled jobs.
Requirements
- A scheduled job ships every order in status `PACKED`, oldest `packedAt` first: it books the parcel with the carrier through the existing `CarrierClient.book(reference)`, then sets the order's `status` to `SHIPPED`, its `trackingCode` to the code the carrier returned and its `shippedAt` to the injected `Clock`'s current instant. Orders in any other status are left alone.
- A carrier booking typically takes 3 to 8 seconds. `CarrierClient` throws `CarrierException` on any failure or timeout; such an order must stay `PACKED` and is retried on the next run, and the other orders of the run still ship. Booking is idempotent on the order reference (booking the same reference again returns the existing tracking code), so a retry after any failed run cannot book a second parcel. A database error may abort the whole run; the orders it did not ship stay `PACKED` and the next run retries them.
- Customers can change the delivery address of a `PLACED` or `PACKED` order at any time through the existing `PATCH /api/orders/{id}/address` endpoint, including while this job is booking that order. An address change the endpoint accepted (200) before the order became `SHIPPED` must be the `shippingAddress` of the shipped order: the courier reads the address from the order row at pickup, and the label carries only the reference.
- Only this job moves an order out of `PACKED` (a packed order cannot be cancelled), and one instance of the fulfilment service runs, so the job never overlaps itself. The warehouse packs at most a few hundred orders a day.
Files touched
- src/main/java/com/acme/fulfilment/orders/OrderRepository.java
- src/main/java/com/acme/fulfilment/shipping/ShipmentWorker.java
--- src/main/java/com/acme/fulfilment/orders/OrderRepository.java
package com.acme.fulfilment.orders;
+import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
""")
int updateAddress(@Param("id") long id, @Param("customerId") long customerId, @Param("address") String address);
+
+ List<Order> findByStatusOrderByPackedAtAsc(OrderStatus status);
}
--- src/main/java/com/acme/fulfilment/shipping/ShipmentWorker.java
+package com.acme.fulfilment.shipping;
+
+import com.acme.fulfilment.orders.Order;
+import com.acme.fulfilment.orders.OrderRepository;
+import com.acme.fulfilment.orders.OrderStatus;
+import java.time.Clock;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+public class ShipmentWorker {
+
+ private static final Logger log = LoggerFactory.getLogger(ShipmentWorker.class);
+
+ private final OrderRepository orders;