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
@@ -24,43 +24,50 @@ public class DeliveryTransferController {
@Resource private DeliveryTransferService service;
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
@Operation(summary = "Create one pending outbound order and one pending delivery order")
public ApiResponse<DeliveryResponses.TransferResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
requirePlatform(user);
return ApiResponse.success(service.platformTransfer(dispatchNo, operator(user), request));
}
@PostMapping("/outbound-orders/one-click-dropship")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
public ApiResponse<DeliveryResponses.PdaTask> createOneClick(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@Valid @RequestBody DeliveryRequests.OneClickOutbound request) {
return ApiResponse.success(service.createOneClick(scope(user, customerId), operator(user), request));
return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request));
}
@GetMapping("/outbound-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.outboundPage(listScope(user, customerId), query));
}
@GetMapping("/outbound-orders/{outboundNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
requirePlatform(user);
return ApiResponse.success(service.outboundDetail(outboundNo, query));
}
@PostMapping("/outbound-orders/{outboundNo}/cancel")
@Operation(summary = "Cancel outbound and linked delivery, restoring inventory")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CANCEL + "')")
@Operation(summary = "Cancel an unexecuted outbound order and its linked delivery")
public ApiResponse<Void> cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String outboundNo,
@Valid @RequestBody DeliveryRequests.Cancel request) {
service.cancelOutbound(scope(user, customerId), operator(user), outboundNo, request);
requirePlatform(user);
service.cancelOutbound(outboundNo, request);
return ApiResponse.success(null);
}
@@ -77,25 +84,27 @@ public class DeliveryTransferController {
public ApiResponse<DeliveryResponses.DeliveryDetail> deliveryDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo));
return ApiResponse.success(service.deliveryDetail(requiredCustomer(user, customerId), deliveryNo));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
private String requiredCustomer(AuthenticatedUser user, String requestedCustomer) {
requirePlatform(user);
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target customer");
}
return requestedCustomer.trim();
}
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.trim();
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private String listScope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim();
return String.valueOf(user.tenantId());
requirePlatform(user);
return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim();
}
}
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.security.OverseasPermissions;
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;
@@ -19,19 +21,22 @@ public class MerchantDeliveryController {
@Resource private DeliveryTransferService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')")
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(customer(user), query));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')")
public ApiResponse<DeliveryResponses.TransferResult> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String dispatchNo,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.transfer(customer(user), dispatchNo, operator(user), request));
return ApiResponse.success(service.merchantTransfer(customer(user), dispatchNo, operator(user), request));
}
@GetMapping("/{deliveryNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')")
public ApiResponse<DeliveryResponses.DeliveryDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo));
@@ -5,37 +5,46 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
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;
@RestController
@RequestMapping("/api/pda/v1/last-leg/outbound-tasks")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class OutboundPdaController {
@Resource private DeliveryTransferService service;
@PostMapping("/{taskNo}/pick")
public ApiResponse<DeliveryResponses.PdaTask> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @RequestParam(required=false) String customerId,
@PathVariable String taskNo,
@Valid @RequestBody DeliveryRequests.PdaPick request) {
return ApiResponse.success(service.pdaPick(scope(user, customerId), operator(user), taskNo, request));
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaPick(String.valueOf(identity.overseasWarehouseId()),
identity.operatorKey(), taskNo, request));
}
@GetMapping
public ApiResponse<java.util.List<DeliveryResponses.PdaTask>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false) String customerId, @RequestParam(required=false) String status) {
return ApiResponse.success(service.pdaTasks(scope(user, customerId), status));
@RequestParam(required=false) String status) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaTasks(String.valueOf(identity.overseasWarehouseId()), status));
}
@GetMapping("/{taskNo}")
public ApiResponse<DeliveryResponses.PdaTaskDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaTaskDetail(String.valueOf(identity.overseasWarehouseId()), taskNo));
}
@PostMapping("/{taskNo}/complete")
public ApiResponse<DeliveryResponses.PdaTask> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @RequestParam(required=false) String customerId) {
return ApiResponse.success(service.completePda(scope(user, customerId), taskNo));
@PathVariable String taskNo, @Valid @RequestBody DeliveryRequests.PdaComplete request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.completePda(String.valueOf(identity.overseasWarehouseId()), taskNo, request));
}
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (requested != null && !requested.isBlank()) return requested.trim();
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -56,5 +56,6 @@ public final class DeliveryRequests {
@NotBlank @Size(max=128) String sku) {}
public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {}
public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {}
public record Cancel(@NotBlank @Size(max=1000) String reason) {}
}
@@ -42,4 +42,11 @@ public final class DeliveryResponses {
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
String skuCode, String inboundCode, long availablePackages,
long pickedPackages) {}
public record PdaTaskItem(Long outboundPalletItemId, String palletNo, String skuCode, String inboundCode,
int packageCount, int pickedPackageCount, String status,
List<PdaPickPosition> positions) {}
public record PdaTaskDetail(PdaTask task, List<PdaTaskItem> items) {}
}
@@ -14,6 +14,7 @@ public class OutboundPdaTask {
private Long outboundOrderId;
private String taskNo;
private String status;
private String completionIdempotencyKey;
private LocalDateTime pushedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
@@ -10,10 +10,14 @@ import org.apache.ibatis.annotations.Select;
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1")
OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1")
OutboundOrder findGlobalByNo(@Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE")
OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE")
OutboundOrder lockGlobalByNo(@Param("outboundNo") String outboundNo);
}
@@ -13,4 +13,22 @@ public interface OutboundPdaTaskMapper extends BaseMapper<OutboundPdaTask> {
OutboundPdaTask lockByNo(@Param("customerId") String customerId, @Param("taskNo") String taskNo);
@Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200")
List<OutboundPdaTask> list(@Param("customerId") String customerId, @Param("status") String status);
@Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " +
"AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} " +
"LIMIT 1 FOR UPDATE")
OutboundPdaTask lockByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId);
@Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " +
"AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1")
OutboundPdaTask findByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId);
@Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " +
"AND o.customer_id=t.customer_id WHERE o.overseas_warehouse_id=#{warehouseId} " +
"AND (#{status} IS NULL OR t.status=#{status}) ORDER BY t.pushed_at DESC LIMIT 200")
List<OutboundPdaTask> listByWarehouse(@Param("warehouseId") String warehouseId, @Param("status") String status);
@Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} " +
"AND completion_idempotency_key=#{key} LIMIT 1")
OutboundPdaTask findByCompletionKey(@Param("customerId") String customerId, @Param("key") String key);
}
@@ -34,6 +34,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
@@ -69,6 +71,7 @@ public class DeliveryTransferService {
@Resource private OutboundPdaTaskMapper pdaTasks;
@Resource private OutboundPdaPickMapper pdaPicks;
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryCommandService inventoryCommands;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipCartonMapper dropshipCartons;
@@ -81,11 +84,26 @@ public class DeliveryTransferService {
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Transactional
public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
DispatchOrder dispatch = dispatchOrders.lockGlobalByDispatchNo(trim(dispatchNo));
if (dispatch == null) throw notFound("Dispatch order not found");
return transferResolved(dispatch, operatorId, request);
}
@Transactional
public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
if (dispatch == null) throw notFound("Dispatch order not found");
return transferResolved(dispatch, operatorId, request);
}
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
DeliveryRequests.Transfer request) {
String customerId = dispatch.getCustomerId();
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
@@ -228,15 +246,42 @@ public class DeliveryTransferService {
}
@Transactional
public DeliveryResponses.PdaTask pdaPick(String customerId, String operatorId, String taskNo, DeliveryRequests.PdaPick request) {
OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found");
OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim()); if (replay != null) return taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task);
OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo(task.getOutboundOrderId()));
public DeliveryResponses.PdaTask pdaPick(String warehouseId, String operatorId, String taskNo,
DeliveryRequests.PdaPick request) {
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
required(warehouseId, "PDA warehouse binding is required"));
if (task == null) throw notFound("PDA task not found in the bound warehouse");
String customerId = task.getCustomerId();
OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!task.getId().equals(replay.getTaskId())
|| !request.outboundPalletItemId().equals(replay.getOutboundPalletItemId())
|| !request.inventoryPositionId().equals(replay.getInventoryPositionId())
|| !request.packages().equals(replay.getPackages())) {
throw conflict("Idempotency key was reused with a different outbound pick request");
}
return taskResult(requireOutbound(task), task);
}
OutboundOrder existingOutbound = requireOutbound(task);
OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo());
if (outbound == null || !required(warehouseId, "PDA warehouse binding is required")
.equals(outbound.getOverseasWarehouseId())) throw notFound("Outbound order not found in the bound warehouse");
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
throw conflict("PDA task is not available for picking");
}
OutboundPalletItem item = outboundPallets.selectById(request.outboundPalletItemId());
if (item == null || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
if (item == null || !customerId.equals(item.getCustomerId())
|| !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity");
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
throw conflict("Scanned inventory position does not belong to the bound warehouse");
}
InventoryBalance selectedBalance = inventoryBalances.selectById(selectedPosition.getBalanceId());
if (!matchesOutboundItem(customerId, warehouseId, item, selectedBalance)) {
throw conflict("Scanned inventory position does not belong to the outbound item");
}
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
List<DropshipCarton> cartons = dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
@@ -258,37 +303,60 @@ public class DeliveryTransferService {
}
@Transactional
public DeliveryResponses.PdaTask completePda(String customerId, String taskNo) {
OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found");
OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId());
public DeliveryResponses.PdaTask completePda(String warehouseId, String taskNo,
DeliveryRequests.PdaComplete request) {
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
required(warehouseId, "PDA warehouse binding is required"));
if (task == null) throw notFound("PDA task not found in the bound warehouse");
String customerId = task.getCustomerId();
OutboundOrder outbound = requireOutbound(task);
String completionKey = request.idempotencyKey().trim();
OutboundPdaTask completionReplay = pdaTasks.findByCompletionKey(customerId, completionKey);
if (completionReplay != null && !task.getId().equals(completionReplay.getId())) {
throw conflict("Idempotency key belongs to another outbound PDA task");
}
if ("COMPLETED".equals(task.getStatus())) {
if (completionKey.equals(task.getCompletionIdempotencyKey())) {
return taskResult(outbound, task);
}
throw conflict("PDA task was already completed with another idempotency key");
}
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
throw conflict("PDA task is not available for completion");
}
List<OutboundPalletItem> items = outboundPallets.findByOutbound(customerId, outbound.getId());
if (items.stream().anyMatch(i -> value(i.getPickedPackageCount()) < value(i.getPackageCount()))) throw conflict("All outbound pallets must be picked before completion");
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); return taskResult(outbound, task);
if (items.isEmpty() || items.stream().anyMatch(i -> value(i.getPickedPackageCount()) != value(i.getPackageCount()))) {
throw conflict("All outbound pallets and packages must be picked before completion");
}
LocalDateTime now = LocalDateTime.now();
task.setStatus("COMPLETED"); task.setCompletionIdempotencyKey(completionKey);
task.setCompletedAt(now); task.setUpdatedAt(now); pdaTasks.updateById(task);
outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
return taskResult(outbound, task);
}
@Transactional
public void cancelOutbound(String customerId, String operatorId, String outboundNo, DeliveryRequests.Cancel request) {
OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo);
public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) {
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found");
if ("CANCELLED".equals(outbound.getStatus())) return;
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
throw conflict("Only an unexecuted outbound order can be cancelled");
}
String customerId = outbound.getCustomerId();
List<OutboundPalletItem> outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId());
if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> !"PICKED".equals(i.getStatus())))
throw conflict("Outbound order can only be cancelled after picking is completed");
if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> value(i.getPickedPackageCount()) > 0
|| !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) {
throw conflict("Outbound order cannot be cancelled after execution has started");
}
DeliveryOrder existingDelivery = deliveryOrders.lockByOutbound(customerId, outbound.getId());
if (existingDelivery != null && !"WAIT_DELIVERY".equals(existingDelivery.getStatus()))
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
LocalDateTime now = LocalDateTime.now();
for (OutboundPdaPick pick : pdaPicks.findByOutbound(customerId, outbound.getId())) {
inventoryCommands.adjust(customerId, operatorId,
new InventoryCommandRequests.Adjustment(pick.getInventoryPositionId(), pick.getPackages(), "OUTBOUND_CANCEL",
outbound.getOutboundNo(), outbound.getOutboundNo() + ":CANCEL:" + pick.getId(), request.reason()));
OutboundPalletItem pickedItem = outboundItems.stream().filter(i -> Objects.equals(i.getId(), pick.getOutboundPalletItemId())).findFirst().orElse(null);
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, pick.getInventoryPositionId());
if (profile != null && pickedItem != null) dropshipSkuInventory.restore(customerId, profile.getId(), pickedItem.getSkuCode(), pick.getPackages());
}
dropshipCartons.restoreOutbound(customerId, outbound.getOutboundNo());
DeliveryOrder delivery = existingDelivery;
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId());
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
if (delivery != null) {
@@ -299,10 +367,79 @@ public class DeliveryTransferService {
}
}
public List<DeliveryResponses.PdaTask> pdaTasks(String customerId, String status) { return pdaTasks.list(customerId, StringUtils.hasText(status) ? status : null).stream().map(task -> taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task)).toList(); }
public List<DeliveryResponses.PdaTask> pdaTasks(String warehouseId, String status) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream()
.map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList();
}
public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"),
boundWarehouseId);
if (task == null) throw notFound("PDA task not found in the bound warehouse");
OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId);
List<DeliveryResponses.PdaTaskItem> items = outboundPallets.findByOutbound(task.getCustomerId(),
task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(),
item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()),
value(item.getPickedPackageCount()), item.getStatus(),
pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList();
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
}
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
OutboundPalletItem item) {
if (item.getWarehousePalletId() == null) return List.of();
List<DeliveryResponses.PdaPickPosition> result = new ArrayList<>();
for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) {
long available = value(position.getAvailablePackages());
if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue;
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
if (!matchesOutboundItem(customerId, warehouseId, item, balance)) continue;
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId());
List<String> marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId())
.stream().filter(carton -> "AVAILABLE".equals(carton.getStatus()))
.filter(carton -> Objects.equals(item.getSkuCode(), carton.getSkuCode()))
.map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList();
if (marks.isEmpty()) {
result.add(pdaPosition(position, item, balance, null, available, value(position.getPickedPackages())));
} else {
for (String mark : marks) result.add(pdaPosition(position, item, balance, mark, 1, 0));
}
}
return result;
}
private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item,
InventoryBalance balance, String cartonMark,
long available, long picked) {
return new DeliveryResponses.PdaPickPosition(position.getId(), position.getPalletNo(), cartonMark,
balance.getSkuCode(), balance.getInboundCode(), available, picked);
}
private boolean matchesOutboundItem(String customerId, String warehouseId, OutboundPalletItem item,
InventoryBalance balance) {
return balance != null && customerId.equals(balance.getCustomerId())
&& warehouseId.equals(balance.getOverseasWarehouseId())
&& Objects.equals(item.getSkuCode(), balance.getSkuCode())
&& Objects.equals(item.getInboundCode(), balance.getInboundCode());
}
private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); }
private String outboundNo(Long id) { OutboundOrder value = outboundOrders.selectById(id); return value == null ? "" : value.getOutboundNo(); }
private OutboundOrder requireOutbound(OutboundPdaTask task) {
OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId());
if (outbound == null || !task.getCustomerId().equals(outbound.getCustomerId())) {
throw new IllegalStateException("Outbound PDA task has no scoped outbound order");
}
return outbound;
}
private OutboundOrder requireOutboundInWarehouse(OutboundPdaTask task, String warehouseId) {
OutboundOrder outbound = requireOutbound(task);
if (!warehouseId.equals(outbound.getOverseasWarehouseId())) {
throw notFound("PDA task not found in the bound warehouse");
}
return outbound;
}
private int value(Integer value) { return value == null ? 0 : value; }
private long value(Long value) { return value == null ? 0L : value; }
@@ -316,10 +453,11 @@ public class DeliveryTransferService {
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
}
public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo,
public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo,
DeliveryRequests.PalletQuery query) {
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found");
String customerId = outbound.getCustomerId();
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery));
}
@@ -404,6 +542,11 @@ public class DeliveryTransferService {
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
return normalized;
}
private static String firstText(String primary, String fallback) { return StringUtils.hasText(primary) ? primary.trim() : trim(fallback); }
private static <T> PageResult<T> page(Page<?> page, List<T> records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }