feat: complete phase 5 overseas fulfillment contracts

This commit is contained in:
2026-09-01 14:24:59 +08:00
parent ffed9bf898
commit 97168b19d8
46 changed files with 2446 additions and 409 deletions
@@ -4,24 +4,20 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -30,60 +26,35 @@ import org.springframework.web.server.ResponseStatusException;
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow;
@PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')")
@Operation(summary = "Edit vehicle, driver, appointment and route data")
public ApiResponse<DispatchResponse> update(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchRequests.Update request) {
return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request));
requirePlatform(user);
return ApiResponse.success(service.update(dispatchNo, request));
}
@DeleteMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo) {
service.cancel(scope(user, customerId), dispatchNo);
requirePlatform(user);
service.cancel(dispatchNo);
return ApiResponse.success(null);
}
@PostMapping("/{dispatchNo}/depart")
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
public ApiResponse<DispatchPdaResponses.Departed> depart(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.Depart request) {
requirePdaOperate(user);
String scopedTenant = scope(user, customerId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
private void requirePdaOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
DispatchPdaController.OPERATE_PERMISSION + " permission is required");
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
}
}
@@ -46,10 +46,10 @@ public final class DispatchRequests {
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
@Size(max = 255) String route, LocalDateTime departAt,
@Size(max = 255) String route,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) {
LocalDateTime estimatedArrivalAt) {
}
}
@@ -6,15 +6,16 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} ORDER BY customer_id LIMIT 2")
List<DispatchOrder> findAcrossCustomersByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE")
DispatchOrder lockGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
@@ -5,65 +5,60 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}")
@RequestMapping("/api/pda/v1/last-leg/dispatches")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class DispatchPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private DispatchPdaWorkflowService service;
@PostMapping("/push")
public ApiResponse<DispatchPdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Push request) {
requireOperate(user);
return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request));
}
@GetMapping
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.task(scope(user, customerId), dispatchNo));
public ApiResponse<List<DispatchPdaResponses.Task>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String status) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.list(String.valueOf(identity.overseasWarehouseId()), status));
}
@PostMapping("/items/{itemId}/pick")
@GetMapping("/{dispatchNo}")
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.task(String.valueOf(identity.overseasWarehouseId()), dispatchNo));
}
@PostMapping("/{dispatchNo}/items/{itemId}/pick")
public ApiResponse<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, @PathVariable long itemId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Pick request) {
requireOperate(user);
return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request));
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pick(String.valueOf(identity.overseasWarehouseId()), dispatchNo, itemId,
identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/complete")
@PostMapping("/{dispatchNo}/complete")
public ApiResponse<DispatchPdaResponses.PickingCompleted> completePicking(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
requireOperate(user);
return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request));
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.completePicking(String.valueOf(identity.overseasWarehouseId()),
dispatchNo, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer.trim();
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
private void requireOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
@PostMapping("/{dispatchNo}/depart")
public ApiResponse<DispatchPdaResponses.Departed> depart(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.Depart request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo,
identity.operatorKey(), request));
}
}
@@ -8,19 +8,8 @@ import jakarta.validation.constraints.Size;
public final class DispatchPdaRequests {
private DispatchPdaRequests() {}
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName,
String warehouseId) {
public Push(String idempotencyKey, String operatorId, String operatorName) {
this(idempotencyKey, operatorId, operatorName, null);
}
}
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark, String warehouseId) {
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
}
}
@NotBlank String idempotencyKey, String remark) {}
public record Depart(@NotBlank String idempotencyKey, String remark) {}
public record CompletePicking(@NotBlank String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) {}
public record CompletePicking(@NotBlank String idempotencyKey) {}
}
@@ -8,4 +8,9 @@ import org.apache.ibatis.annotations.*;
public interface DispatchPdaOperationMapper extends BaseMapper<DispatchPdaOperation> {
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND task_id=#{taskId} " +
"AND operation_type=#{operationType} AND status='COMPLETED' ORDER BY created_at DESC LIMIT 1")
DispatchPdaOperation findCompleted(@Param("customerId") String customerId, @Param("taskId") Long taskId,
@Param("operationType") String operationType);
}
@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
@@ -11,4 +13,9 @@ public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_pda_task WHERE warehouse_id=#{warehouseId} " +
"AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200")
List<DispatchPdaTask> listByWarehouse(@Param("warehouseId") String warehouseId,
@Param("status") String status);
}
@@ -44,59 +44,48 @@ public class DispatchPdaWorkflowService {
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@Transactional
public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Task.class);
items.bindReceivedPallets(customerId, order.getId());
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push");
if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
throw conflict("All pre-arranged pallets must be received before PDA push");
@Transactional(readOnly = true)
public List<DispatchPdaResponses.Task> list(String warehouseId, String status) {
String boundWarehouseId = warehouse(warehouseId);
List<DispatchPdaResponses.Task> result = new ArrayList<>();
for (DispatchPdaTask pdaTask : tasks.listByWarehouse(boundWarehouseId, trim(status))) {
DispatchOrder order = orders.selectById(pdaTask.getDispatchOrderId());
if (order == null || !pdaTask.getCustomerId().equals(order.getCustomerId())
|| !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) {
throw conflict("Dispatch PDA task scope is inconsistent");
}
result.add(task(order.getCustomerId(), order, pdaTask,
items.findByDispatchOrder(order.getCustomerId(), order.getId())));
}
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
LocalDateTime now = LocalDateTime.now();
if (task == null) {
task = new DispatchPdaTask();
task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(order.getId());
task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING");
task.setWarehouseId(warehouseId);
task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName()));
task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task);
}
if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); }
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse");
DispatchPdaResponses.Task result = task(customerId, order, task, dispatchItems);
save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result);
return result;
}
@Transactional(readOnly = true)
public DispatchPdaResponses.Task task(String customerId, String dispatchNo) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
@Transactional
public DispatchPdaResponses.Task task(String warehouseId, String dispatchNo) {
DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = order.getCustomerId();
items.bindReceivedPallets(customerId, order.getId());
DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId());
if (task == null) throw notFound("Dispatch PDA task not found");
requireTaskWarehouse(task, warehouseId);
return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId()));
}
@Transactional
public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId,
String operatorId, DispatchPdaRequests.Pick request) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId,
String operatorId, String operatorName,
DispatchPdaRequests.Pick request) {
DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = order.getCustomerId();
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
items.bindReceivedPallets(customerId, order.getId());
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for picking");
}
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse");
bindOperator(task, operatorId, operatorName);
DispatchItem item = items.selectById(itemId);
if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) {
throw notFound("Dispatch pallet item not found");
@@ -145,9 +134,10 @@ public class DispatchPdaWorkflowService {
}
@Transactional
public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo,
public DispatchPdaResponses.PickingCompleted completePicking(String warehouseId, String dispatchNo,
DispatchPdaRequests.CompletePicking request) {
DispatchOrder found = requireOrder(customerId, dispatchNo);
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = found.getCustomerId();
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request);
@@ -159,8 +149,12 @@ public class DispatchPdaWorkflowService {
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
return new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), order.getStatus(), planned,
picked, Math.max(0, planned - picked), picked < planned, true);
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(
order.getDispatchNo(), order.getStatus(), planned, picked, Math.max(0, planned - picked), false, false);
DispatchPdaOperation completed = operations.findCompleted(customerId, task.getId(), "COMPLETE_PICKING");
if (completed != null) throw conflict("PDA picking was already completed with another idempotency key");
save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
return result;
}
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for completion");
@@ -169,25 +163,27 @@ public class DispatchPdaWorkflowService {
long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count");
boolean partial = picked < planned;
if (planned <= 0 || picked != planned || !allPicked(customerId, task.getId(), source)) {
throw conflict("Every dispatch pallet and package must be fully picked before completion");
}
LocalDateTime now = LocalDateTime.now();
task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task);
order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART");
order.setPartialCompletionReason(partial ? request.reason().trim() : null);
order.setStatus("READY_TO_DEPART");
order.setPartialCompletionReason(null);
order.setPickedPackageCount((int) picked);
order.setUpdatedAt(now); orders.updateById(order);
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(),
order.getStatus(), planned, picked, planned - picked, partial, false);
order.getStatus(), planned, picked, 0, false, false);
save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
return result;
}
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
@Transactional
public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId,
public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId,
DispatchPdaRequests.Depart request) {
DispatchOrder found = requireOrder(customerId, dispatchNo);
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = found.getCustomerId();
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
@@ -199,13 +195,13 @@ public class DispatchPdaWorkflowService {
}
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !"COMPLETED".equals(task.getStatus())
|| !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) {
|| !"READY_TO_DEPART".equals(order.getStatus())) {
throw conflict("Dispatch must complete PDA picking before departure");
}
requireTaskWarehouse(task, warehouseId);
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus());
if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) {
if (!allPicked(customerId, task.getId(), dispatchItems)) {
throw conflict("Every dispatch pallet must be fully picked before departure");
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
@@ -217,7 +213,7 @@ public class DispatchPdaWorkflowService {
outbound.setUpdatedAt(now);
outboundOrders.updateById(outbound);
order.setShippedPackageCount((int) shipped);
order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED");
order.setStatus("DEPARTED");
order.setUpdatedAt(now);
orders.updateById(order);
releasePalletLocks(customerId, order.getId(), now);
@@ -258,18 +254,33 @@ public class DispatchPdaWorkflowService {
palletLocks.updateById(lock);
}
}
private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; }
private DispatchOrder requireWarehouseOrder(String warehouseId, String dispatchNo) {
String boundWarehouseId = warehouse(warehouseId);
DispatchOrder order = orders.findGlobalByDispatchNo(required(dispatchNo, "Dispatch number is required"));
if (order == null || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) {
throw notFound("Dispatch order not found in the bound warehouse");
}
return order;
}
private void requirePdaStatus(DispatchOrder order) {
if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus())
|| "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA");
}
private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); }
private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); }
private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) {
if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) {
throw conflict("Selected warehouse does not match dispatch warehouse");
private static void requireTaskWarehouse(DispatchPdaTask task, String warehouseId) {
if (!warehouse(warehouseId).equals(trim(task.getWarehouseId()))) {
throw notFound("Dispatch PDA task not found in the bound warehouse");
}
}
private static void bindOperator(DispatchPdaTask task, String operatorId, String operatorName) {
String normalizedOperator = required(operatorId, "PDA operator is required");
if (trim(task.getOperatorId()) != null && !normalizedOperator.equals(trim(task.getOperatorId()))) {
throw conflict("Dispatch PDA task is already assigned to another operator");
}
task.setOperatorId(normalizedOperator);
task.setOperatorName(trim(operatorName));
}
private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; }
private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); }
private <T> T read(DispatchPdaOperation op, Class<T> type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } }
@@ -20,6 +20,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper;
import com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse;
import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -49,6 +51,7 @@ public class DispatchService {
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchDraftContainerMapper draftContainers;
@Resource private DispatchCandidateMapper candidates;
@Resource private DispatchPdaTaskMapper pdaTasks;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
@@ -315,6 +318,17 @@ public class DispatchService {
item.setCreatedAt(now);
items.insert(item);
}
DispatchPdaTask pdaTask = new DispatchPdaTask();
pdaTask.setId(IdWorker.getId());
pdaTask.setCustomerId(customerId);
pdaTask.setDispatchOrderId(dispatch.getId());
pdaTask.setTaskNo("DPDA" + pdaTask.getId());
pdaTask.setStatus("PDA_PENDING");
pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId());
pdaTask.setPushedAt(now);
pdaTask.setCreatedAt(now);
pdaTask.setUpdatedAt(now);
pdaTasks.insert(pdaTask);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@@ -351,49 +365,45 @@ public class DispatchService {
}
public DispatchResponse platformDetail(String dispatchNo) {
List<DispatchOrder> matches = orders.findAcrossCustomersByDispatchNo(dispatchNo);
if (matches == null || matches.isEmpty()) {
DispatchOrder dispatch = orders.findGlobalByDispatchNo(trimToNull(dispatchNo));
if (dispatch == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
}
if (matches.size() > 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Dispatch number is not unique across customers");
}
DispatchOrder dispatch = matches.get(0);
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
@Transactional
public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
// Vehicle plate is intentionally editable through all dispatch states.
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Only a pending dispatch can be edited");
}
String customerId = dispatch.getCustomerId();
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
dispatch.setCarrierName(trimToNull(request.carrierName()));
dispatch.setRoute(trimToNull(request.route()));
dispatch.setDepartAt(request.departAt());
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
if (StringUtils.hasText(request.status())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Dispatch status is PDA-managed and cannot be changed by the edit endpoint");
}
dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
public void cancel(String customerId, String dispatchNo) {
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
public void cancel(String dispatchNo) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
if (CANCELLED.equals(dispatch.getStatus())) return;
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled");
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
}
String customerId = dispatch.getCustomerId();
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
dispatch.setUpdatedAt(now);