Merge origin/dev and resolve backend conflicts

This commit is contained in:
2026-09-08 20:50:28 +08:00
342 changed files with 13161 additions and 2296 deletions
@@ -7,12 +7,14 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LastlegOrderCommandService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
import com.baiyue.common.event.EventTypes;
@@ -45,6 +47,7 @@ public class LastlegOrderController {
@Resource private LoadingCompletedConsumer consumer;
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
@Resource private LastlegOrderService orderService;
@Resource private LastlegOrderCommandService commandService;
@Resource private InternalServiceTokenGuard tokenGuard;
@Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer;
@@ -102,6 +105,20 @@ public class LastlegOrderController {
return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId));
}
@PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "修改整柜海外接柜仓",
description = "仅在海外PDA建立接柜会话前允许修改;按 expectedVersion 乐观锁原子同步柜内订单与入库计划。")
public ApiResponse<ContainerPlanProgressSummary> changeReceivingWarehouse(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String sourceContainerBookingId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody LastlegOrderRequests.ChangeReceivingWarehouse request) {
platformScope(user, null);
return ApiResponse.success(orderService.changeReceivingWarehouse(sourceContainerBookingId,
idempotencyKey, request, operatorName(user)));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
@@ -123,17 +140,43 @@ public class LastlegOrderController {
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user)));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/detained")
@PutMapping("/api/platform/v1/last-leg/orders/{id}/hold")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Void> setDetained(
@Operation(summary = "暂扣或解除单票尾程订单",
description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<LastlegOrderCommandDtos.HoldResponse> hold(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SetDetained request) {
orderService.setDetained(platformScope(user, customerId), id, request,
String.valueOf(user.userId()), operatorName(user));
return ApiResponse.success(null);
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody LastlegOrderCommandDtos.HoldRequest request) {
return ApiResponse.success(commandService.hold(platformScope(user, customerId), id, idempotencyKey,
request, operatorId(user), operatorName(user)));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "保存单票人工节点覆盖",
description = "首次保存后该票永久使用 MANUAL 节点,不再回退柜级节点;当前无清除或恢复继承接口。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<LastlegOrderCommandDtos.ManualNodesResponse> saveManualNodes(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody LastlegOrderCommandDtos.ManualNodesRequest request) {
return ApiResponse.success(commandService.saveManualNodes(platformScope(user, customerId), id,
idempotencyKey, request, operatorId(user), operatorName(user)));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "查询单票人工节点覆盖",
description = "只返回人工填写节点及 MANUAL 来源。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<LastlegOrderCommandDtos.ManualNodesResponse> manualNodes(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id) {
return ApiResponse.success(commandService.manualNodes(platformScope(user, customerId), id));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required")
@@ -213,4 +256,8 @@ public class LastlegOrderController {
private String operatorName(AuthenticatedUser user) {
return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username();
}
private String operatorId(AuthenticatedUser user) {
return user.userId() == null ? operatorName(user) : String.valueOf(user.userId());
}
}
@@ -68,8 +68,8 @@ public class CustomerVisitController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')")
public ApiResponse<CustomerVisitResponses.Completed> completeDelivery(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo));
@PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) {
return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo, request));
}
@PutMapping("/delivery-orders/{deliveryNo}/status")
@@ -31,5 +31,6 @@ public final class CustomerVisitRequests {
@NotNull @Min(0) Long sizeBytes) { }
public record DeliveryStatus(@NotBlank @Size(max = 32) String status,
@NotNull @Min(0) Long expectedVersion,
@Size(max = 500) String remark) { }
}
@@ -12,12 +12,11 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -51,7 +50,6 @@ public class CustomerVisitService {
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private LastlegOrderMapper lastMileOrders;
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private CustomerVisitFileGateway files;
@Resource private ObjectMapper objectMapper;
@@ -79,31 +77,31 @@ public class CustomerVisitService {
}
@Transactional
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (Set.of("RETURN_PENDING", "REJECTED", "CANCELLED").contains(delivery.getStatus())) {
throw conflict("当前派送状态不能标记为派送完成");
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo,
CustomerVisitRequests.DeliveryStatus request) {
if (!"DELIVERED".equals(trim(request.status()).toUpperCase(Locale.ROOT))) {
throw badRequest("派送完成接口只接受 DELIVERED 状态");
}
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
boolean replayed = "DELIVERED".equals(delivery.getStatus());
if (!replayed) {
requireVersion(delivery, request.expectedVersion());
requireDeliveryTransition(delivery.getStatus(), "DELIVERED");
}
LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now();
if (!replayed) {
delivery.setStatus("DELIVERED");
delivery.setSignedAt(now);
delivery.setUpdatedAt(now);
delivery.setVersion(version(delivery) + 1);
deliveryOrders.updateById(delivery);
for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) {
String from = order.getCurrentStatus();
order.setCurrentStatus("DELIVERED");
order.setSubOrderStatus("DELIVERY:DELIVERED");
order.setUpdatedAt(now);
lastMileOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from);
history.setToStatus("DELIVERED"); history.setEventId("DELIVERY_COMPLETED:" + deliveryNo);
history.setRemark("派送完成"); history.setCreatedAt(now); histories.insert(history);
}
recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now);
}
@@ -116,23 +114,25 @@ public class CustomerVisitService {
String deliveryNo,
CustomerVisitRequests.DeliveryStatus request) {
String target = trim(request.status()).toUpperCase(Locale.ROOT);
if (!Set.of("WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED").contains(target)) {
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
throw badRequest("不支持的派送状态");
}
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo);
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo, request);
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if ("RETURN_PENDING".equals(delivery.getStatus())) throw conflict("退货处理中的派送单不能直接修改状态");
if (target.equals(delivery.getStatus())) {
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
delivery.getStatus(), existing == null ? null : existing.getStatus(), true);
}
requireVersion(delivery, request.expectedVersion());
requireDeliveryTransition(delivery.getStatus(), target);
String from = delivery.getStatus();
LocalDateTime now = LocalDateTime.now();
delivery.setStatus(target);
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
delivery.setUpdatedAt(now);
delivery.setVersion(version(delivery) + 1);
deliveryOrders.updateById(delivery);
String result = "派送状态由 " + from + " 修改为 " + target;
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
@@ -309,6 +309,15 @@ public class CustomerVisitService {
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static void requireDeliveryTransition(String from, String target) {
if (!DeliveryStatusPolicy.canTransition(from, target)) {
throw conflict("派送状态不允许由 " + from + " 变更为 " + target);
}
}
private static void requireVersion(DeliveryOrder delivery, long expectedVersion) {
if (version(delivery) != expectedVersion) throw conflict("派送单版本已变化,请刷新后重试");
}
private static long version(DeliveryOrder delivery) { return Objects.requireNonNullElse(delivery.getVersion(), 0L); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -42,6 +42,15 @@ public class DeliveryTransferController {
return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request));
}
@GetMapping("/outbound-orders/one-click-dropship/candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "List server-authoritative one-click dropship outbound candidates")
public ApiResponse<java.util.List<DeliveryResponses.OneClickCandidate>> oneClickCandidates(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@RequestParam(required = false) String warehouseId) {
return ApiResponse.success(service.oneClickCandidates(requiredCustomer(user, customerId), warehouseId));
}
@GetMapping("/outbound-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@@ -55,9 +64,9 @@ public class DeliveryTransferController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
@PathVariable String outboundNo) {
requirePlatform(user);
return ApiResponse.success(service.outboundDetail(outboundNo, query));
return ApiResponse.success(service.outboundDetail(outboundNo));
}
@PostMapping("/outbound-orders/{outboundNo}/cancel")
@@ -34,6 +34,13 @@ public class OutboundPdaController {
return ApiResponse.success(service.pdaTasks(String.valueOf(identity.overseasWarehouseId()), status));
}
@GetMapping("/resolve/{businessNo}")
public ApiResponse<DeliveryResponses.PdaTaskResolution> resolve(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String businessNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.resolvePdaTask(String.valueOf(identity.overseasWarehouseId()), businessNo));
}
@GetMapping("/{taskNo}")
public ApiResponse<DeliveryResponses.PdaTaskDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo) {
@@ -24,7 +24,7 @@ public final class DeliveryResponses {
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
String remark, LocalDateTime createdAt) {}
String remark, LocalDateTime createdAt, long version, List<String> allowedActions) {}
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
@@ -40,10 +40,16 @@ public final class DeliveryResponses {
public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus,
List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations) {}
public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String skuCode, String inboundCode,
Integer packageCount, Integer pickedPackageCount, String status) {}
/** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */
public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode,
String destinationWarehouseCode, String destinationWarehouseName,
String skuCode, int packageCount, int pickedPackageCount, String status) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, List<OutboundPalletRow> pallets) {}
public record OneClickCandidate(Long inventoryPositionId, String overseasWarehouseId,
String overseasWarehouseName, String palletNo, String inboundCode,
String skuCode, String productName, long availablePackages) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
String skuCode, String inboundCode, long availablePackages,
long pickedPackages) {}
@@ -47,6 +47,7 @@ public class DeliveryOrder {
private String remark;
private String idempotencyKey;
private String createdBy;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -10,6 +10,8 @@ import org.apache.ibatis.annotations.Select;
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE")
DeliveryOrder lockByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
@@ -23,6 +23,12 @@ public interface OutboundPdaTaskMapper extends BaseMapper<OutboundPdaTask> {
"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 t.customer_id=#{customerId} AND t.outbound_order_id=#{outboundOrderId} " +
"AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1")
OutboundPdaTask findByOutboundAndWarehouse(@Param("customerId") String customerId,
@Param("outboundOrderId") Long outboundOrderId, @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")
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.delivery.model;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class DeliveryStatusPolicy {
private static final Set<String> EDITABLE_TARGETS = Set.of(
"WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"),
"DELIVERING", Set.of("DELIVERED", "REJECTED"),
"DELIVERED", Set.of(),
"REJECTED", Set.of(),
"RETURN_PENDING", Set.of(),
"RETURNED", Set.of(),
"CANCELLED", Set.of());
private DeliveryStatusPolicy() { }
public static boolean isEditableTarget(String status) {
return EDITABLE_TARGETS.contains(status);
}
public static boolean canTransition(String from, String target) {
return TRANSITIONS.getOrDefault(from, Set.of()).contains(target);
}
public static List<String> allowedActions(String status) {
return switch (status == null ? "" : status) {
case "WAIT_DELIVERY" -> List.of(
"START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN");
default -> List.of();
};
}
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
@@ -40,6 +41,7 @@ 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;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
@@ -116,6 +118,7 @@ public class DeliveryTransferService {
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
throw conflict("Dispatch cannot be transferred to delivery in its current status");
}
dispatchItems.bindReceivedPallets(customerId, dispatch.getId());
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
@@ -130,7 +133,7 @@ public class DeliveryTransferService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId()));
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY");
outbound.setLxCodes(items.stream().map(DispatchItem::getInboundCode).filter(StringUtils::hasText).map(code -> tailOrders.selectOne(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, code))).filter(Objects::nonNull).map(LastlegOrder::getLxCode).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(",")));
outbound.setLxCodes(resolveLxCodes(customerId, items));
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
@@ -178,7 +181,7 @@ public class DeliveryTransferService {
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName());
LastlegOrder firstOrder = firstTailOrder(customerId, items);
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY");
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim());
delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod()));
delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize()));
@@ -250,30 +253,35 @@ public class DeliveryTransferService {
return taskResult(outbound, task);
}
public List<DeliveryResponses.OneClickCandidate> oneClickCandidates(String customerId, String warehouseId) {
return dropshipProfiles.listOutboundCandidates(required(customerId, "Customer is required"),
StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream()
.map(this::oneClickCandidate).toList();
}
private String validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
String warehouseId = null;
Map<String, Long> requestedPackages = new LinkedHashMap<>();
Map<String, DropshipOrderProfile> profiles = new HashMap<>();
Map<String, DropshipOutboundCandidate> candidates = new HashMap<>();
for (DeliveryRequests.OneClickItem input : items) {
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId());
if (profile == null) throw conflict("Selected pallet is not one-click dropship inventory");
if (warehouseId == null) warehouseId = position.getOverseasWarehouseId();
else if (!Objects.equals(warehouseId, position.getOverseasWarehouseId())) {
throw conflict("One outbound order must use inventory from the same overseas warehouse");
String sku = input.sku().trim();
DropshipOutboundCandidate candidate = dropshipProfiles.lockOutboundCandidate(customerId,
input.inventoryPositionId(), sku);
if (candidate == null) {
throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用");
}
if (value(position.getAvailablePackages()) < input.packages()) {
throw conflict("Insufficient available inventory for pallet " + position.getPalletNo());
if (warehouseId == null) warehouseId = candidate.getOverseasWarehouseId();
else if (!Objects.equals(warehouseId, candidate.getOverseasWarehouseId())) {
throw conflict("一张出库单只能选择同一海外仓的库存");
}
String key = profile.getId() + "\u0000" + input.sku().trim();
String key = candidate.getProfileId() + "\u0000" + sku;
requestedPackages.merge(key, input.packages(), Long::sum);
profiles.put(key, profile);
candidates.put(key, candidate);
if (input.cartonMarks() != null && !input.cartonMarks().isEmpty()) {
if (input.cartonMarks().size() != input.packages() || new HashSet<>(input.cartonMarks()).size() != input.cartonMarks().size()) {
throw conflict("Specified carton marks must be unique and equal the package count");
}
List<DropshipCarton> selected = dropshipCartons.listProfile(customerId, profile.getId()).stream()
List<DropshipCarton> selected = dropshipCartons.listProfile(customerId, candidate.getProfileId()).stream()
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && input.sku().trim().equals(carton.getSkuCode())
&& input.cartonMarks().contains(carton.getCartonMark())).toList();
if (selected.size() != input.cartonMarks().size()) throw conflict("Specified carton marks are unavailable");
@@ -281,10 +289,14 @@ public class DeliveryTransferService {
}
for (Map.Entry<String, Long> requested : requestedPackages.entrySet()) {
String sku = requested.getKey().substring(requested.getKey().indexOf('\u0000') + 1);
DropshipOutboundCandidate candidate = candidates.get(requested.getKey());
if (requested.getValue() > value(candidate.getAvailablePackages())) {
throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku);
}
List<DropshipCarton> cartons = dropshipCartons.lockAvailable(customerId,
profiles.get(requested.getKey()).getId(), sku, requested.getValue());
candidate.getProfileId(), sku, requested.getValue());
if (cartons.size() != requested.getValue()) {
throw conflict("Insufficient available one-click dropship cartons for SKU " + sku);
throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku);
}
}
return warehouseId;
@@ -399,7 +411,7 @@ public class DeliveryTransferService {
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
LocalDateTime now = LocalDateTime.now();
DeliveryOrder delivery = existingDelivery;
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); 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);
@@ -432,6 +444,36 @@ public class DeliveryTransferService {
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
}
public DeliveryResponses.PdaTaskResolution resolvePdaTask(String warehouseId, String businessNo) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
String code = required(businessNo, "Outbound task, outbound or dispatch number is required");
OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(code, boundWarehouseId);
if (task != null) {
OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId);
return outboundResolution(outbound, task);
}
OutboundOrder outbound = outboundOrders.findGlobalByNo(code);
if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo());
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(),
dispatch.getDispatchNo());
}
}
task = pdaTasks.findByOutboundAndWarehouse(outbound.getCustomerId(), outbound.getId(), boundWarehouseId);
if (task != null) return outboundResolution(outbound, task);
}
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code);
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo());
}
throw notFound("Outbound or dispatch task not found in the bound warehouse");
}
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
OutboundPalletItem item) {
if (item.getWarehousePalletId() == null) return List.of();
@@ -471,6 +513,10 @@ public class DeliveryTransferService {
}
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 DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) {
return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(),
outbound.getDispatchNo());
}
private OutboundOrder requireOutbound(OutboundPdaTask task) {
OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId());
if (outbound == null || !task.getCustomerId().equals(outbound.getCustomerId())) {
@@ -499,14 +545,13 @@ public class DeliveryTransferService {
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
}
public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo,
DeliveryRequests.PalletQuery query) {
public DeliveryResponses.OutboundDetail outboundDetail(String 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());
List<DeliveryResponses.OutboundPalletRow> pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream()
.map(item -> new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getSkuCode(), item.getInboundCode(), item.getPackageCount(), item.getPickedPackageCount(), item.getStatus())).toList();
.map(this::outboundPalletRow).toList();
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), pallets);
}
@@ -582,12 +627,33 @@ public class DeliveryTransferService {
d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed);
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); }
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
item.getSkuCode(), value(item.getPackageCount()), value(item.getPickedPackageCount()), item.getStatus());
}
private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) {
return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(),
candidate.getOverseasWarehouseId(), candidate.getOverseasWarehouseName(), candidate.getPalletNo(),
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
value(candidate.getAvailablePackages()));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
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 String resolveLxCodes(String customerId, List<DispatchItem> items) {
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
if (inboundCodes.isEmpty()) return "";
return tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getInboundCode, inboundCodes))
.stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining(","));
}
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) {
@@ -24,6 +24,7 @@ public class DispatchPalletLock {
private Long planPalletId;
private Long warehousePalletId;
private Long dispatchOrderId;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long draftContainerId;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String draftToken;
@@ -36,7 +36,13 @@ public interface DispatchCandidateMapper {
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
@@ -60,6 +66,10 @@ public interface DispatchCandidateMapper {
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
@@ -94,7 +104,13 @@ public interface DispatchCandidateMapper {
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
@@ -112,6 +128,10 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
@@ -7,8 +7,16 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE draft_token=#{draftToken} " +
"AND draft_container_id=#{draftContainerId} AND status='ACTIVE' " +
"AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY locked_at,id")
List<DispatchPalletLock> findActiveByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@@ -22,6 +22,8 @@ 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 com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -53,6 +55,7 @@ public class DispatchService {
@Resource private DispatchCandidateMapper candidates;
@Resource private DispatchPdaTaskMapper pdaTasks;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private LastlegOrderMapper tailOrders;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -95,7 +98,8 @@ public class DispatchService {
private List<DispatchContainerResponse> containers(String token) {
return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(),
locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>().eq(DispatchPalletLock::getDraftContainerId, c.getId()).eq(DispatchPalletLock::getStatus, "ACTIVE")).stream().map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList();
locks.findActiveByDraftContainer(token, c.getId()).stream()
.map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList();
}
@Transactional
@@ -220,10 +224,17 @@ public class DispatchService {
if (selected.size() != palletIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
}
assertNoDetainedOrders(customerId, selected);
boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.anyMatch(value -> value == null || value.isBlank());
if (missingWarehouse) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"所选托盘中存在尚未关联海外仓的托盘");
}
Set<String> warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet());
if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse");
.map(String::trim).collect(Collectors.toSet());
if (warehouseIds.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘");
}
String draftToken = trimToNull(request.draftToken());
for (DispatchPalletCandidate candidate : selected) {
@@ -268,6 +279,7 @@ public class DispatchService {
if (draftToken != null) {
requireDraftOwner(lock, operatorId, draftToken);
lock.setDispatchOrderId(dispatch.getId());
lock.setDraftContainerId(null);
lock.setDraftToken(null);
lock.setExpiresAt(null);
lock.setUpdatedAt(now);
@@ -329,9 +341,37 @@ public class DispatchService {
pdaTask.setCreatedAt(now);
pdaTask.setUpdatedAt(now);
pdaTasks.insert(pdaTask);
if (draftToken != null) {
draftContainers.delete(new LambdaQueryWrapper<DispatchDraftContainer>()
.eq(DispatchDraftContainer::getDraftToken, draftToken));
DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId));
if (draft != null) {
draft.setStatus("RELEASED");
draft.setReleasedAt(now);
draft.setUpdatedAt(now);
drafts.updateById(draft);
}
}
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) {
List<Long> orderIds = selected.stream().map(DispatchPalletCandidate::orderId)
.filter(Objects::nonNull).distinct().sorted().toList();
if (orderIds.isEmpty()) return;
List<LastlegOrder> scoped = tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getId, orderIds)
.orderByAsc(LastlegOrder::getId)
.last("FOR UPDATE"));
List<String> detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained()))
.map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList();
if (!detainedNos.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos));
}
}
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
if (!StringUtils.hasText(customerId)) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
@@ -2,13 +2,53 @@ package com.baiyue.lastleg.dropship.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProfile> {
String OUTBOUND_CANDIDATE_SELECT = "SELECT pos.id inventory_position_id,p.id profile_id," +
"p.warehouse_pallet_id,pos.overseas_warehouse_id,b.overseas_warehouse_name," +
"p.pallet_no,b.inbound_code,s.sku_code,s.product_name," +
"LEAST(pos.available_packages,s.package_count,(SELECT COUNT(*) FROM ll_dropship_carton c " +
"WHERE c.customer_id=p.customer_id AND c.profile_id=p.id AND c.sku_code=s.sku_code " +
"AND c.status='AVAILABLE')) available_packages FROM ll_dropship_order_profile p " +
"JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id " +
"AND o.order_type='ONE_CLICK_DROPSHIP' AND COALESCE(o.detained,0)=0 " +
"JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=p.customer_id " +
"AND pos.pallet_id=p.warehouse_pallet_id " +
"JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"JOIN ll_dropship_sku_inventory s ON s.customer_id=p.customer_id AND s.order_id=p.order_id " +
"AND s.profile_id=p.id ";
String OUTBOUND_CANDIDATE_WHERE = "WHERE p.customer_id=#{customerId} AND p.status='ACTIVE' " +
"AND pos.status='OCCUPIED' AND pos.available_packages>0 AND s.package_count>0 " +
"AND EXISTS (SELECT 1 FROM ll_dropship_carton c WHERE c.customer_id=p.customer_id " +
"AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE') " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock dl WHERE dl.customer_id=p.customer_id " +
"AND dl.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id) AND dl.status='ACTIVE' " +
"AND (dl.expires_at IS NULL OR dl.expires_at>CURRENT_TIMESTAMP(3))) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"WHERE ta.customer_id=p.customer_id AND ta.inventory_position_id=pos.id AND ta.status='RESERVED') " +
"AND NOT EXISTS (SELECT 1 FROM ll_outbound_pallet_item oi JOIN ll_outbound_order oo " +
"ON oo.id=oi.outbound_order_id AND oo.customer_id=oi.customer_id " +
"WHERE oi.customer_id=p.customer_id AND oi.warehouse_pallet_id=p.warehouse_pallet_id " +
"AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) ";
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no")
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1")
DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("<script>" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"<if test='warehouseId != null and warehouseId != \"\"'>AND pos.overseas_warehouse_id=#{warehouseId} </if>" +
"ORDER BY b.overseas_warehouse_name,p.pallet_no,s.sku_code LIMIT 500</script>")
List<DropshipOutboundCandidate> listOutboundCandidates(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
@Select(OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"AND pos.id=#{positionId} AND s.sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
DropshipOutboundCandidate lockOutboundCandidate(@Param("customerId") String customerId,
@Param("positionId") Long positionId,
@Param("skuCode") String skuCode);
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.dropship.model;
import lombok.Data;
/** Server-authoritative one-click dropship inventory dimension eligible for outbound creation. */
@Data
public class DropshipOutboundCandidate {
private Long inventoryPositionId;
private Long profileId;
private Long warehousePalletId;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String palletNo;
private String inboundCode;
private String skuCode;
private String productName;
private Long availablePackages;
}
@@ -135,8 +135,8 @@ public class DropshipOrderService {
throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying");
}
Map<String, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
.filter(receipt -> receipt.getPalletNo() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getPalletNo));
.filter(receipt -> receipt.getSourcePalletNo() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getSourcePalletNo));
if (receiptsByPallet.size() != profilesForOrder.size()) {
throw conflict("PDA pallet count does not match dropship source pallet count");
}
@@ -6,6 +6,12 @@ import java.time.LocalDateTime;
public record ContainerPlanProgressSummary(
String sourceContainerBookingId,
String containerNo,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
int assignmentVersion,
boolean canChangeReceivingWarehouse,
String receivingWarehouseLockReason,
long totalOrderCount,
long inboundCodeCount,
long missingInboundCodeCount,
@@ -16,4 +22,14 @@ public record ContainerPlanProgressSummary(
boolean allPlansReady,
String blockingReason
) {
public ContainerPlanProgressSummary(String sourceContainerBookingId, String containerNo,
long totalOrderCount, long inboundCodeCount,
long missingInboundCodeCount, long planCount,
long plannedInboundCodeCount, long pendingPlanCount,
LocalDateTime plannedArrivalAt, boolean allPlansReady,
String blockingReason) {
this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null,
totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount,
plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason);
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Min;
import java.time.LocalDateTime;
import java.util.List;
public final class LastlegOrderCommandDtos {
private LastlegOrderCommandDtos() { }
public record HoldRequest(@NotNull Boolean held, @NotNull Boolean chargeable,
@NotBlank @Size(max = 500) String reason,
@NotNull @Min(0) Integer expectedVersion) { }
public record HoldResponse(long orderId, boolean held, boolean chargeable, String reason, int version) { }
public record ManualNode(@NotBlank @Size(max = 32) String nodeCode,
LocalDateTime nodeAt) { }
public record ManualNodesRequest(@NotNull @Min(0) Integer expectedVersion,
@NotEmpty @Size(min = 8, max = 8) List<@Valid ManualNode> nodes) { }
public record ManualNodesResponse(long orderId, boolean manualOverrideEnabled,
List<ManualNodeView> nodes, int version) { }
public record ManualNodeView(String nodeCode, LocalDateTime nodeAt, String source) { }
}
@@ -9,6 +9,7 @@ import java.util.List;
public record LastlegOrderDetailResponse(
LastlegOrderSummary order,
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes,
List<LastlegOrderDetail> cargo,
List<History> statusHistory,
List<LastlegOrderFee> fees,
@@ -54,6 +54,12 @@ public final class LastlegOrderRequests {
}
}
public record ChangeReceivingWarehouse(
@NotNull Long overseasWarehouseId,
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String remark
) { }
public record TransferDestination(
@NotEmpty @Size(max = 200) List<Long> orderIds,
@NotBlank @Size(max = 128) String warehouseId,
@@ -15,12 +15,14 @@ public record LastlegOrderSummary(
@Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"})
String destinationType,
String sourceInboundId,
@Schema(description = "真实来源入仓码;无入仓关系的海外本土业务为空")
String inboundCode,
String lxCode,
String sourceOrderNo,
String customerId,
String customerName,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String transferWarehouseId,
String transferWarehouseCode,
@@ -30,6 +32,11 @@ public record LastlegOrderSummary(
String containerStatus,
Boolean abnormal,
Boolean detained,
Boolean heldChargeable,
String holdReason,
boolean manualOverrideEnabled,
String nodeSource,
int version,
Integer palletCount,
Integer packageCount,
BigDecimal volumeCbm,
@@ -28,7 +28,9 @@ public class LastlegOrder {
private String sourceWarehouseId;
private String sourceWarehouseName;
private String overseasWarehouseId;
private String overseasWarehouseCode;
private String overseasWarehouseName;
private Integer receivingWarehouseVersion;
private String fbaWarehouseCode;
private String containerNo;
private String transferWarehouseId;
@@ -37,6 +39,10 @@ public class LastlegOrder {
private String containerStatus;
private Boolean abnormal;
private Boolean detained;
private Boolean holdChargeable;
private String holdReason;
private Boolean manualNodeOverride;
private Integer version;
private Integer palletCount;
private Integer packageCount;
private Integer pickedPackageCount;
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_order_command_record")
public class LastlegOrderCommandRecord {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long orderId;
private String action;
private String operatorId;
private String idempotencyKey;
private String requestHash;
private String resultRef;
private String resultJson;
private Integer resultVersion;
private LocalDateTime createdAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_order_hold_history")
public class LastlegOrderHoldHistory {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long orderId;
private Boolean held;
private Boolean chargeable;
private String reason;
private Integer orderVersion;
private String operatorId;
private String operatorName;
private String idempotencyKey;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_order_manual_node")
public class LastlegOrderManualNode {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long orderId;
private String nodeCode;
private LocalDateTime nodeAt;
private String source;
private Integer orderVersion;
private String updatedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_order_manual_node_history")
public class LastlegOrderManualNodeHistory {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long orderId;
private String nodesJson;
private Integer orderVersion;
private String operatorId;
private String operatorName;
private String idempotencyKey;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_warehouse_operation")
public class ReceivingWarehouseOperation {
@TableId(type = IdType.INPUT) private Long id;
private String sourceContainerBookingId;
private String idempotencyKey;
private String requestHash;
private Integer resultVersion;
private String operatorName;
private String remark;
private LocalDateTime createdAt;
}
@@ -10,10 +10,12 @@ import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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 org.springframework.web.multipart.MultipartFile;
import java.util.List;
@@ -45,6 +47,46 @@ public class ExceptionOrderController {
return ApiResponse.success(service.create(scope(user), operatorId(user), operatorName(user), request));
}
@GetMapping("/exception-orders/{exceptionNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')")
public ApiResponse<ExceptionOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String exceptionNo) {
return ApiResponse.success(service.detail(requiredScope(user, customerId), exceptionNo));
}
@PostMapping(value = "/exception-orders/{exceptionNo}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.UploadedEvidence> uploadEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file));
}
@PostMapping("/exception-orders/{exceptionNo}/start-processing")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> startProcessing(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.StartProcessing request) {
return ApiResponse.success(service.startProcessing(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
}
@PostMapping("/exception-orders/{exceptionNo}/complete")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> complete(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Complete request) {
return ApiResponse.success(service.complete(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
}
@PostMapping("/exception-orders/{exceptionNo}/cancel")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> cancel(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Cancel request) {
return ApiResponse.success(service.cancel(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
}
@GetMapping("/delivery-orders/{deliveryNo}/return-inbound-options")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')")
public ApiResponse<List<ExceptionOrderResponses.Relation>> returnOptions(@AuthenticationPrincipal AuthenticatedUser user,
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class ExceptionOrderRequests {
private ExceptionOrderRequests() {}
@@ -25,10 +26,36 @@ public final class ExceptionOrderRequests {
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
public record MarkReturn(@NotNull Long inboundOrderId,
public record MarkReturn(@NotNull @Min(1) Long inboundOrderId,
@NotBlank @Size(max = 1000) String reason,
@NotBlank @Size(max = 32) String returnDirection,
@Size(max = 128) String returnLogisticsNo,
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
@NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId,
@Min(1) long packageCount) {}
public record StartProcessing(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String handlingAction) {}
public record Complete(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 2000) String handlingResult,
@NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds,
@Size(max = 200) List<@NotNull ReturnReceipt> receipts) {}
public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId,
@Min(1) long receivedPackages,
@NotNull @Min(1) Long targetPalletId,
@NotBlank @Size(max = 128) String targetPalletNo,
@NotNull @Min(1) Long targetLocationId,
@NotBlank @Size(max = 128) String targetLocationCode) {}
public record Cancel(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) {}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.exceptionorder.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class ExceptionOrderResponses {
private ExceptionOrderResponses() {}
@@ -13,13 +14,23 @@ public final class ExceptionOrderResponses {
Long deliveryOrderId, String deliveryNo, String outboundNo,
String skuCode, Integer packageCount, String returnDirection,
String returnLogisticsNo, String handlingAction,
String handlerName, LocalDateTime exceptionAt, LocalDateTime updatedAt) {}
String handlingResult, String handlerName, LocalDateTime exceptionAt,
LocalDateTime completedAt, LocalDateTime updatedAt, long version,
List<Long> evidenceFileIds, List<String> allowedActions) {}
public record Relation(Long lastMileOrderId, String lastMileOrderNo,
Long inboundOrderId, String inboundCode,
String customerId, String customerName,
String overseasWarehouseId, String overseasWarehouseName,
String deliveryNo, String outboundNo, Integer availablePackages) {}
String deliveryNo, String outboundNo, Integer availablePackages,
List<ReturnSource> returnSources) {}
public record Created(Long id, String exceptionNo, String status) {}
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId,
String palletNo, String skuCode, long maxReturnPackages) {}
public record Detail(Row order, List<ReturnSource> returnSources) {}
public record CommandResult(Long id, String exceptionNo, String status, long version,
Long inventoryAdjustmentId, long restoredPackages,
LocalDateTime completedAt, boolean replayed) {}
public record UploadedEvidence(Long fileId, String fileName, String contentType, long sizeBytes) {}
public record Created(Long id, String exceptionNo, String status, long version, boolean replayed) {}
}
@@ -32,11 +32,16 @@ public class ExceptionOrder {
private String returnDirection;
private String returnLogisticsNo;
private String handlingAction;
private String handlingResult;
private String evidenceFileIdsJson;
private Long inventoryAdjustmentId;
private String handlerId;
private String handlerName;
private String sourceType;
private String createdBy;
private LocalDateTime exceptionAt;
private LocalDateTime completedAt;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.exceptionorder.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_exception_order_command")
public class ExceptionOrderCommand {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long exceptionOrderId;
private String action;
private String idempotencyKey;
private String requestHash;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.exceptionorder.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_exception_return_item")
public class ExceptionReturnItem {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long exceptionOrderId;
private Long outboundPalletItemId;
private Long inventoryPositionId;
private String palletNo;
private String skuCode;
private Long maxReturnPackages;
private Long approvedReturnPackages;
private LocalDateTime createdAt;
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.exceptionorder.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface ExceptionEvidenceFileGateway {
Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file);
void validate(String customerId, long exceptionOrderId, long fileId);
record Uploaded(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.exceptionorder.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@Component
public class HttpExceptionEvidenceFileGateway implements ExceptionEvidenceFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_EXCEPTION_EVIDENCE";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile());
}
@Override
public Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file) {
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取异常处理证据", exception); }
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId(customerId), BUSINESS_TYPE, exceptionOrderId)
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(
result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"异常处理证据上传失败"); }).body(MetadataEnvelope.class);
Metadata data = require(response);
return new Uploaded(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@Override
public void validate(String customerId, long exceptionOrderId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, exceptionOrderId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(
result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"异常处理证据不存在或归属不正确"); }).body(MetadataEnvelope.class);
Metadata data = require(response);
if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(exceptionOrderId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "异常处理证据与当前异常单不匹配");
}
}
private static Metadata require(MetadataEnvelope response) {
if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回异常处理证据元数据");
}
return response.data();
}
private static long tenantId(String value) {
try { return Long.parseLong(value); }
catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", exception); }
}
private record MetadataEnvelope(String code, String message, Metadata data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.exceptionorder.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ExceptionOrderCommandMapper extends BaseMapper<ExceptionOrderCommand> {
@Select("SELECT * FROM ll_exception_order_command WHERE customer_id=#{customerId} AND action=#{action} AND idempotency_key=#{key} LIMIT 1")
ExceptionOrderCommand findByKey(@Param("customerId") String customerId,
@Param("action") String action,
@Param("key") String key);
}
@@ -12,4 +12,10 @@ public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
"AND exception_type='RETURN' LIMIT 1")
ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1 FOR UPDATE")
ExceptionOrder lockByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo);
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1")
ExceptionOrder findByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.exceptionorder.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
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 ExceptionReturnItemMapper extends BaseMapper<ExceptionReturnItem> {
@Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id")
List<ExceptionReturnItem> findByException(@Param("customerId") String customerId,
@Param("exceptionOrderId") Long exceptionOrderId);
}
@@ -8,26 +8,52 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaPick;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class ExceptionOrderService {
@@ -37,6 +63,14 @@ public class ExceptionOrderService {
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPalletItems;
@Resource private OutboundPdaPickMapper outboundPdaPicks;
@Resource private ExceptionReturnItemMapper returnItems;
@Resource private ExceptionOrderCommandMapper commands;
@Resource private InventoryCommandService inventoryCommands;
@Resource private ExceptionEvidenceFileGateway evidenceFiles;
@Resource private ObjectMapper objectMapper;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId());
@@ -65,14 +99,40 @@ public class ExceptionOrderService {
return relation(scopedCustomerId, requestedCustomerId, inboundCode, lastMileOrderNo);
}
public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order));
}
public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId,
String exceptionNo, MultipartFile file) {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
if (file == null || file.isEmpty()) throw badRequest("请选择异常处理证据文件");
ExceptionEvidenceFileGateway.Uploaded uploaded = evidenceFiles.upload(customerId, order.getId(), operatorId, file);
return new ExceptionOrderResponses.UploadedEvidence(uploaded.fileId(), uploaded.fileName(),
uploaded.contentType(), uploaded.sizeBytes());
}
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
throw conflict("当前派送状态不允许标记退回");
}
return returnCandidates(delivery);
}
private List<ExceptionOrderResponses.Relation> returnCandidates(DeliveryOrder delivery) {
String customerId = delivery.getCustomerId();
String deliveryNo = delivery.getDeliveryNo();
return lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
.orderByAsc(LastlegOrder::getOrderNo)).stream()
.map(order -> relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()))
.map(order -> withReturnSources(
relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery))
.distinct().toList();
}
@@ -88,27 +148,56 @@ public class ExceptionOrderService {
@Transactional
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
throw conflict("当前派送状态不允许标记退回");
}
ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId());
if (existing != null) return new ExceptionOrderResponses.Created(existing.getId(), existing.getExceptionNo(), existing.getStatus());
if (existing != null) throw conflict("该派送单已存在退回异常单");
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(customerId, deliveryNo);
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
List<ExceptionOrderResponses.ReturnSource> available = selected.returnSources();
List<ExceptionOrderRequests.ReturnItem> requested = request.items();
validateReturnItems(available, requested);
int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum());
Set<String> skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item)))
.map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED";
String returnDirection = trim(request.returnDirection()).toUpperCase(java.util.Locale.ROOT);
if (!Set.of("OVERSEAS_WAREHOUSE", "CUSTOMER", "OTHER").contains(returnDirection)) {
throw badRequest("不支持的退回方向");
}
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
request.packageCount(), request.skuCode(), request.returnDirection(), request.returnLogisticsNo(),
packages, sku, returnDirection, request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId, operatorName);
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
ExceptionOrderResponses.ReturnSource source = available.stream()
.filter(candidate -> sameSource(candidate, requestedItem)).findFirst().orElseThrow();
ExceptionReturnItem item = new ExceptionReturnItem();
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id());
item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId());
item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode());
item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount());
item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item);
}
LocalDateTime now = LocalDateTime.now();
delivery.setStatus("RETURN_PENDING");
delivery.setUpdatedAt(now);
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
deliveryOrders.updateById(delivery);
LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId());
if (tail != null) {
tail.setAbnormal(true);
tail.setSubOrderStatus("RETURN_PENDING");
tail.setSubOrderStatus("DELIVERY:RETURN_PENDING");
tail.setUpdatedAt(now);
lastMileOrders.updateById(tail);
}
@@ -117,9 +206,85 @@ public class ExceptionOrderService {
operation.setOperationNode("MARK_RETURN"); operation.setOperatorId(operatorId); operation.setOperatorName(operatorName);
operation.setResult("已生成异常退货单 " + created.exceptionNo()); operation.setCreatedAt(now);
deliveryOperations.insert(operation);
recordCommand(customerId, created.id(), "MARK_RETURN", request.idempotencyKey(), requestHash, created);
return created;
}
@Transactional
public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId,
String exceptionNo,
ExceptionOrderRequests.StartProcessing request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "START_PROCESSING", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING");
LocalDateTime now = LocalDateTime.now();
order.setStatus("PROCESSING"); order.setHandlingAction(trim(request.handlingAction()));
order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
exceptionOrders.updateById(order);
ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false);
recordCommand(customerId, order.getId(), "START_PROCESSING", request.idempotencyKey(), requestHash, result);
return result;
}
@Transactional
public ExceptionOrderResponses.CommandResult cancel(String customerId, String operatorId,
String exceptionNo,
ExceptionOrderRequests.Cancel request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING");
LocalDateTime now = LocalDateTime.now();
order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason()));
order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
exceptionOrders.updateById(order);
ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false);
recordCommand(customerId, order.getId(), "CANCEL", request.idempotencyKey(), requestHash, result);
return result;
}
@Transactional
public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId,
String exceptionNo,
ExceptionOrderRequests.Complete request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "COMPLETE", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId));
ReturnedInventoryRestoreResponse restored = null;
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
List<ExceptionReturnItem> stored = returnItems.findByException(customerId, order.getId());
if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细");
if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细");
restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId,
new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(),
order.getOverseasWarehouseId(), order.getInboundOrderId(),
stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(),
request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt(
receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
request.evidenceFileIds().stream().map(String::valueOf).toList()));
} else if (request.receipts() != null && !request.receipts().isEmpty()) {
throw badRequest("非退回海外仓异常不能提交库存实收明细");
}
LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt();
order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult()));
order.setEvidenceFileIdsJson(write(request.evidenceFileIds()));
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now);
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
closeReturnedDelivery(order, operatorId, now);
long restoredPackages = restored == null ? 0 : restored.restoredPackages();
ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result);
return result;
}
private ExceptionOrderResponses.Relation relation(String scopedCustomerId, String requestedCustomerId,
String inboundCode, String lastMileOrderNo) {
String customerId = customerScope(scopedCustomerId, requestedCustomerId);
@@ -150,7 +315,7 @@ public class ExceptionOrderService {
resolvedCustomer, tail == null ? null : tail.getCustomerName(),
tail == null ? null : tail.getOverseasWarehouseId(), tail == null ? null : tail.getOverseasWarehouseName(),
tail == null ? null : tail.getDeliveryOrderNo(), tail == null ? null : tail.getOutboundOrderNo(),
tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount());
tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of());
}
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
@@ -180,10 +345,11 @@ public class ExceptionOrderService {
value.setHandlerId(operatorId); value.setHandlerName(operatorName);
}
value.setSourceType(sourceType); value.setCreatedBy(operatorId);
value.setVersion(0L);
value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now);
try { exceptionOrders.insert(value); }
catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); }
return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus());
return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value), false);
}
private LastlegOrder uniqueTail(String customerId, String orderNo, String inboundCode) {
@@ -227,8 +393,164 @@ public class ExceptionOrderService {
value.getOverseasWarehouseName(), value.getLastMileOrderId(), value.getLastMileOrderNo(),
value.getInboundOrderId(), value.getInboundCode(), value.getDeliveryOrderId(), value.getDeliveryNo(),
value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(),
value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlerName(),
value.getExceptionAt(), value.getUpdatedAt());
value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlingResult(), value.getHandlerName(),
value.getExceptionAt(), value.getCompletedAt(), value.getUpdatedAt(), version(value),
readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value));
}
private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation,
DeliveryOrder delivery) {
List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream()
.filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode()))
.map(Source::response).toList();
return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(),
relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(),
relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(),
relation.outboundNo(), relation.availablePackages(), sources);
}
private List<Source> returnSources(DeliveryOrder delivery) {
OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo());
if (outbound == null) throw conflict("派送单缺少原出库单据");
Map<Long, OutboundPalletItem> itemById = new LinkedHashMap<>();
outboundPalletItems.findByOutbound(delivery.getCustomerId(), outbound.getId())
.forEach(item -> itemById.put(item.getId(), item));
Map<String, Source> aggregated = new LinkedHashMap<>();
for (OutboundPdaPick pick : outboundPdaPicks.findByOutbound(delivery.getCustomerId(), outbound.getId())) {
OutboundPalletItem item = itemById.get(pick.getOutboundPalletItemId());
if (item == null || pick.getInventoryPositionId() == null) continue;
String key = item.getId() + ":" + pick.getInventoryPositionId();
aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(),
item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L)),
(left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages()));
}
return new ArrayList<>(aggregated.values());
}
private List<ExceptionOrderResponses.ReturnSource> storedReturnSources(ExceptionOrder order) {
return returnItems.findByException(order.getCustomerId(), order.getId()).stream()
.map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(),
item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList();
}
private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available,
List<ExceptionOrderRequests.ReturnItem> requested) {
Set<String> unique = new java.util.HashSet<>();
for (ExceptionOrderRequests.ReturnItem item : requested) {
String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId();
if (!unique.add(key)) throw badRequest("退回库存明细不能重复");
ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item))
.findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单"));
if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数");
}
}
private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) {
return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId())
&& Objects.equals(source.inventoryPositionId(), item.inventoryPositionId());
}
private ExceptionOrder lock(String customerId, String exceptionNo, long expectedVersion, String expectedStatus) {
ExceptionOrder order = exceptionOrders.lockByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
if (version(order) != expectedVersion) throw conflict("异常订单版本已变化,请刷新后重试");
if (!expectedStatus.equals(order.getStatus())) throw conflict("当前异常状态不允许执行该操作");
return order;
}
private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) {
throw conflict("关联派送单不是退货处理中状态");
}
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
deliveryOrders.updateById(delivery);
for (LastlegOrder tail : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, order.getCustomerId())
.eq(LastlegOrder::getDeliveryOrderNo, order.getDeliveryNo()))) {
tail.setAbnormal(false); tail.setSubOrderStatus("DELIVERY:RETURNED"); tail.setUpdatedAt(now);
lastMileOrders.updateById(tail);
}
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(delivery.getId());
operation.setOperationNode("RETURN_COMPLETED"); operation.setOperatorId(operatorId);
operation.setOperatorName(operatorId); operation.setResult("异常退回处理完成:" + order.getExceptionNo());
operation.setCreatedAt(now); deliveryOperations.insert(operation);
}
private List<String> allowedActions(ExceptionOrder order) {
return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION");
case "PROCESSING" -> List.of("COMPLETE_EXCEPTION");
default -> List.of();
};
}
private ExceptionOrderResponses.CommandResult commandResult(ExceptionOrder order, long restoredPackages,
boolean replayed) {
return new ExceptionOrderResponses.CommandResult(order.getId(), order.getExceptionNo(), order.getStatus(),
version(order), order.getInventoryAdjustmentId(), restoredPackages, order.getCompletedAt(), replayed);
}
private void recordCommand(String customerId, Long orderId, String action, String idempotencyKey,
String requestHash, Object result) {
ExceptionOrderCommand command = new ExceptionOrderCommand();
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setExceptionOrderId(orderId);
command.setAction(action); command.setIdempotencyKey(idempotencyKey.trim());
command.setRequestHash(requestHash); command.setResultJson(write(result)); command.setCreatedAt(LocalDateTime.now());
commands.insert(command);
}
private ExceptionOrderResponses.CommandResult replayResult(ExceptionOrderCommand command, String requestHash) {
requireReplayHash(command, requestHash);
try {
ExceptionOrderResponses.CommandResult result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.CommandResult.class);
return new ExceptionOrderResponses.CommandResult(result.id(), result.exceptionNo(), result.status(), result.version(),
result.inventoryAdjustmentId(), result.restoredPackages(), result.completedAt(), true);
} catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); }
}
private ExceptionOrderResponses.Created replayCreated(ExceptionOrderCommand command, String requestHash) {
requireReplayHash(command, requestHash);
try {
ExceptionOrderResponses.Created result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class);
return new ExceptionOrderResponses.Created(result.id(), result.exceptionNo(), result.status(), result.version(), true);
}
catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); }
}
private void requireReplayHash(ExceptionOrderCommand command, String requestHash) {
if (!Objects.equals(command.getRequestHash(), requestHash)) throw conflict("幂等键已用于不同请求");
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存异常处理结果", exception); }
}
private List<Long> readEvidenceFileIds(String value) {
if (!StringUtils.hasText(value)) return List.of();
try { return objectMapper.readValue(value, new TypeReference<>() { }); }
catch (JsonProcessingException exception) { throw new IllegalStateException("异常处理证据数据损坏", exception); }
}
private static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo,
String skuCode, String inboundCode, long maxReturnPackages) {
Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages); }
ExceptionOrderResponses.ReturnSource response() {
return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId,
palletNo, skuCode, maxReturnPackages);
}
}
private String customerScope(String scopedCustomerId, String requestedCustomerId) {
@@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
@@ -44,19 +44,24 @@ public class InboundPlanController {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@PostMapping("/convert")
@PostMapping("/manual")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
/** 由平台人工将待分配尾程订单转换为入仓计划。 */
@Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: "
@Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: "
+ OverseasPermissions.INBOUND_ORDER_CREATE)
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertInboundPlanRequest request) {
return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId()));
public ApiResponse<InboundPlanResponse> manual(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ManualInboundPlanRequest request) {
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可手工创建入仓计划");
}
convertScope(user, request.customerId());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId));
}
@PostMapping("/convert-container")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
@Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
@Operation(summary = "按整柜批量生成剩余入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;保留已有有效计划,只为剩余入仓码分别生成独立计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
public ApiResponse<java.util.List<InboundPlanResponse>> convertContainer(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertContainerInboundPlanRequest request) {
@@ -1,10 +0,0 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record ConvertInboundPlanRequest(
@NotNull Long orderId,
@Size(max = 128) String customerId
) {
}
@@ -12,6 +12,8 @@ public record InboundPlanListResponse(
String inboundCode,
String lxCode,
String orderType,
String overseasWarehouseCode,
String overseasWarehouseName,
String warehouseId,
String warehouseCode,
String warehouseName,
@@ -29,18 +31,4 @@ public record InboundPlanListResponse(
String dispatchStatus,
LocalDateTime createdAt,
Integer version
) {
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
Long orderId, String inboundCode, String orderType,
String warehouseId, String warehouseCode, String warehouseName,
String fbaWarehouseCode, String customerName, Integer packageCount,
BigDecimal volumeCbm, Integer palletCount, String status,
String appointmentNo, String appointmentStatus,
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
String dispatchStatus, LocalDateTime createdAt) {
this(lineId, planId, planNo, customerId, orderId, inboundCode, null, orderType, warehouseId,
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt, 0);
}
}
) { }
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.List;
public record InboundPlanResponse(
@@ -10,6 +11,8 @@ public record InboundPlanResponse(
String customerId,
Long sourceOrderId,
String containerNo,
String overseasWarehouseCode,
String overseasWarehouseName,
String status,
int lineCount,
int palletCount,
@@ -19,24 +22,15 @@ public record InboundPlanResponse(
List<Line> lines,
List<Pallet> pallets,
List<Allocation> allocations,
int version
int version,
LocalDate plannedArrivalDate,
String palletType,
boolean assigned,
boolean mergeRequired,
boolean urgent,
String creationMode,
String remark
) {
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines, pallets, allocations, 0);
}
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations, int version) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version);
}
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
@@ -46,7 +40,7 @@ public record InboundPlanResponse(
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo,
Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId,
String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm,
String palletType, String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm,
int actualPackageCount) {
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record ManualInboundPlanRequest(
@NotBlank @Size(max = 128) String customerId,
@NotNull Long orderId,
@NotNull @Min(0) Integer expectedOrderVersion,
@NotNull LocalDate plannedArrivalDate,
@NotNull @Min(1) @Max(999) Integer palletCount,
@NotBlank @Size(max = 32) String palletType,
@NotNull Boolean assigned,
@NotNull Boolean mergeRequired,
@NotNull Boolean urgent,
@Size(max = 1000) String remark
) { }
@@ -8,6 +8,7 @@ import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
@Data
@TableName("ll_inbound_plan")
@@ -19,6 +20,13 @@ public class InboundPlan {
private Long sourceOrderId;
private String containerNo;
private Long overseasWarehouseId;
private LocalDate plannedArrivalDate;
private String palletType;
private Boolean assignedFlag;
private Boolean mergeRequired;
private Boolean urgentFlag;
private String creationMode;
private String planRemark;
private String status;
private Integer lineCount;
private Integer palletCount;
@@ -16,6 +16,8 @@ public class InboundPlanPallet {
private String customerId;
private Long planId;
private String palletNo;
private String sourcePalletNo;
private String palletType;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
@@ -14,6 +14,19 @@ import java.util.List;
public interface InboundPlanOrderMapper {
record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo,
String orderType) { }
record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName) { }
record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId,
String overseasWarehouseId) { }
@Select("SELECT c.customer_id AS customerId,c.id AS orderId," +
"c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " +
"FROM ll_order target JOIN ll_order c ON c.source_container_booking_id=target.source_container_booking_id " +
"WHERE target.customer_id=#{customerId} AND target.id=#{orderId} " +
"AND target.source_container_booking_id IS NOT NULL AND target.source_container_booking_id!='' " +
"ORDER BY c.customer_id,c.id FOR UPDATE")
List<ContainerWarehouseRef> lockContainerWarehouseFacts(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," +
"order_type AS orderType " +
@@ -26,22 +39,30 @@ public interface InboundPlanOrderMapper {
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
"COALESCE(o.pallet_count,0) AS recommendedPalletCount," +
"COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," +
"o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," +
"NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
"0 AS destinationMismatchCount " +
"0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," +
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count," +
"o.overseas_warehouse_id,o.overseas_warehouse_code,o.overseas_warehouse_name,o.fba_warehouse_code," +
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name,o.detained,o.version " +
"ORDER BY o.id FOR UPDATE")
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT overseas_warehouse_id AS overseasWarehouseId," +
"overseas_warehouse_code AS overseasWarehouseCode,overseas_warehouse_name AS overseasWarehouseName " +
"FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId}")
@@ -49,6 +70,17 @@ public interface InboundPlanOrderMapper {
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
@Param("updatedAt") LocalDateTime updatedAt);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," +
"pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " +
"AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL")
int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
@Param("mergeRequired") boolean mergeRequired,
@Param("expectedVersion") int expectedVersion,
@Param("updatedAt") LocalDateTime updatedAt);
@Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,remark,created_at) " +
"VALUES(#{customerId},#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})")
int insertHistory(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("fromStatus") String fromStatus,
@@ -25,7 +25,7 @@ public interface InboundPlanQueryMapper {
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.lxCode != null and q.lxCode != \"\"'> AND o.lx_code LIKE CONCAT('%',#{q.lxCode},'%')</if>" +
"<if test='q.status != null and q.status != \"\"'> AND p.status=#{q.status}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND l.destination_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND o.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.warehouse != null and q.warehouse != \"\"'> AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))</if>" +
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
@@ -38,6 +38,7 @@ public interface InboundPlanQueryMapper {
@Select("<script>SELECT l.id lineId,p.id planId,p.plan_no planNo,p.customer_id customerId," +
"l.order_id orderId,l.inbound_code inboundCode,o.lx_code lxCode,o.order_type orderType," +
"o.overseas_warehouse_code overseasWarehouseCode,o.overseas_warehouse_name overseasWarehouseName," +
"l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," +
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
"o.customer_name customerName,l.required_package_count packageCount," +
@@ -12,30 +12,27 @@ public record SourceOrderSnapshot(
BigDecimal volumeCbm,
int packageCount,
int recommendedPalletCount,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String fbaWarehouseCode,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int destinationCount,
int destinationMismatchCount
int destinationMismatchCount,
boolean detained,
int version
) {
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
int packageCount, int recommendedPalletCount, String overseasWarehouseId,
String overseasWarehouseCode, String overseasWarehouseName, String fbaWarehouseCode,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, 0, fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, 0, null, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
packageCount, recommendedPalletCount, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
destinationCount, destinationMismatchCount, false, 0);
}
}
@@ -6,22 +6,46 @@ import java.util.List;
public final class ReceivingContainerResponses {
private ReceivingContainerResponses() { }
public record Candidate(String sourceContainerBookingId, String containerNo, String status,
public record Candidate(String sourceContainerBookingId, String containerNo,
String overseasWarehouseCode, String overseasWarehouseName, String status,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages,
boolean operable, boolean allPlansReady, String blockReason,
LocalDateTime plannedArrivalAt, boolean currentAccount) { }
LocalDateTime plannedArrivalAt, boolean currentAccount) {
public Candidate(String sourceContainerBookingId, String containerNo, String status,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
List<String> destinationCodes, int plannedPalletCount, int totalPackages,
int receivedPackages, boolean operable, boolean allPlansReady,
String blockReason, LocalDateTime plannedArrivalAt, boolean currentAccount) {
this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount,
inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount,
pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages,
receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount);
}
}
public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { }
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName,
String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) { }
List<Inbound> inbounds) {
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version, List<SignedPhoto> photos,
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
LocalDateTime completedAt, int totalPackages, int receivedPackages,
int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
receivedPackages, pendingPackages, inbounds);
}
}
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
@@ -25,6 +25,7 @@ public class InboundStockReceipt {
private String productNameEn;
private Long warehousePalletId;
private String palletNo;
private String sourcePalletNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private String batchNo;
@@ -20,6 +20,7 @@ public class ReceivingContainerSession {
private String containerFactHash;
private String containerNo;
private Long overseasWarehouseId;
private String overseasWarehouseCode;
private String overseasWarehouseName;
private String operatorKey;
private String operatorName;
@@ -59,10 +59,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
"businessId", businessId, "reservationKey", reservationKey))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode());
throw warehouseReservationFailure(result.getStatusCode());
})
.body(ReserveEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response");
if (response == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse resource reservation returned no response");
}
if (response.data() == null) {
HttpStatus status = "A0409".equals(response.code()) ? HttpStatus.CONFLICT
: "A0404".equals(response.code()) ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE;
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank()
? "Warehouse resource reservation was rejected" : response.message());
}
return response.data().lockId();
}
@@ -120,6 +130,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
requireReservable(response.data());
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(),
response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version());
@@ -133,6 +144,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
requireReservable(response.data());
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
response.data().unitPrice(), response.data().version());
@@ -144,6 +156,26 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
}
private ResponseStatusException warehouseReservationFailure(HttpStatusCode status) {
HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT
: status.value() == 404 ? HttpStatus.NOT_FOUND
: status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE;
return new ResponseStatusException(mapped, "Warehouse resource reservation was rejected");
}
private void requireReservable(WarehousePalletData pallet) {
if (pallet.reservable()) return;
String reason = switch (pallet.reservationBlockReason() == null
? "RESOURCE_STATE_UNKNOWN" : pallet.reservationBlockReason()) {
case "PALLET_NOT_IDLE" -> "Pallet is not idle";
case "LOCATION_NOT_FOUND" -> "Pallet location was not found";
case "VIRTUAL_LOCATION_NOT_ACTIVE" -> "Pallet virtual location is not active";
case "LOCATION_NOT_IDLE" -> "Pallet location is not idle";
default -> "Pallet resource availability could not be verified";
};
throw new ResponseStatusException(HttpStatus.CONFLICT, reason);
}
@Override
public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
WarehouseLocationResponse response = client.get()
@@ -200,8 +232,10 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
private record WarehousePalletResponse(String code, String message, WarehousePalletData data) {}
private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {}
private record ReserveEnvelope(ReserveData data) {}
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version,
String locationType, String locationStatus, boolean reservable,
String reservationBlockReason) {}
private record ReserveEnvelope(String code, String message, ReserveData data) {}
private record ReserveData(long lockId) {}
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {}
@@ -171,13 +171,17 @@ public class InboundPdaWorkflowService {
if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null;
throw conflict("Destination pallet is already bound to another physical pallet");
}
if (resource.palletNo() == null || resource.palletNo().isBlank()) {
throw conflict("Resolved physical pallet has no pallet code");
}
String destinationKey = destinationKey(pallet);
String businessId = plan.getContainerNo() + ":" + destinationKey;
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId();
long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(),
"INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey);
try {
pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId());
pallet.setWarehousePalletId(resource.palletId()); pallet.setPalletNo(resource.palletNo().trim());
pallet.setWarehouseLocationId(resource.locationId());
pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet);
return lockId;
@@ -941,7 +945,7 @@ public class InboundPdaWorkflowService {
boolean wholePalletInbound = "ONE_CLICK_DROPSHIP".equals(orderType);
Map<String, InboundPlanPallet> palletsByNo = wholePalletInbound
? repository.pallets(plan.getId()).stream().collect(java.util.stream.Collectors.toMap(
InboundPlanPallet::getPalletNo, value -> value)) : Map.of();
InboundPlanPallet::getSourcePalletNo, value -> value)) : Map.of();
Map<Long, InboundPlanPalletAllocation> allocationsByPallet = wholePalletInbound
? allocations.stream().collect(java.util.stream.Collectors.toMap(
InboundPlanPalletAllocation::getPalletId, value -> value)) : Map.of();
@@ -15,6 +15,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -43,6 +44,7 @@ public class InboundStockReceiptService {
@Resource private InboundStockReceiptMapper receipts;
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
@Resource private InventoryCommandService inventory;
@Resource private WarehouseResourceGateway warehouseGateway;
@Resource private DropshipOrderService dropshipOrders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@@ -116,7 +118,7 @@ public class InboundStockReceiptService {
if (value(allocation.getPickedPackageCount()) != value(allocation.getPlannedPackageCount())) {
throw conflict("One-click dropship inbound must receive a complete source pallet");
}
DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getPalletNo());
DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getSourcePalletNo());
if (sourcePallet == null || sourcePallet.packageCount() != value(allocation.getPickedPackageCount())) {
throw conflict("One-click dropship source pallet SKU package total does not match PDA receipt");
}
@@ -228,6 +230,8 @@ public class InboundStockReceiptService {
"Actual overseas warehouse is required for stock-in");
String overseasWarehouseName = required(order.getOverseasWarehouseName(),
"Actual overseas warehouse name is required for stock-in");
String overseasWarehouseCode = required(order.getOverseasWarehouseCode(),
"Actual overseas warehouse code is required for stock-in");
if (plan.getOverseasWarehouseId() == null
|| !overseasWarehouseId.equals(String.valueOf(plan.getOverseasWarehouseId()))) {
throw conflict("Inbound plan and tail-leg order overseas warehouses must match for stock-in");
@@ -235,12 +239,20 @@ public class InboundStockReceiptService {
if (overseasWarehouseId.equals(trim(order.getSourceWarehouseId()))) {
throw conflict("Actual overseas warehouse must not use the domestic source warehouse");
}
WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation(
Long.parseLong(overseasWarehouseId), pallet.getWarehouseLocationId());
if (location == null || location.locationId() != pallet.getWarehouseLocationId()) {
throw conflict("Assigned warehouse location could not be resolved for stock-in");
}
String warehouseLocationCode = required(location.locationCode(),
"Assigned warehouse location code is required for stock-in");
InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName,
overseasWarehouseId, null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode,
overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
pallet.getWarehouseLocationId(), warehouseLocationCode, packages, order.getOrderNo(),
order.getSourceEventId(), operationKey,
"PDA inbound completion for " + line.getInboundCode());
InventoryCommandResponse result = Boolean.TRUE.equals(order.getDetained())
@@ -261,6 +273,7 @@ public class InboundStockReceiptService {
receipt.setProductNameEn(sku.productNameEn());
receipt.setWarehousePalletId(pallet.getWarehousePalletId());
receipt.setPalletNo(pallet.getPalletNo());
receipt.setSourcePalletNo(pallet.getSourcePalletNo());
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId());
receipt.setBatchNo(plan.getPlanNo());
receipt.setReceivedPackages(packages);
@@ -126,7 +126,7 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse");
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
requireReceivingAvailable(customerId, containerNo);
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
@@ -158,31 +158,31 @@ public class ReceivingContainerService {
}
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
requireReceivingAvailable(customerId, containerNo);
String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
requireSessionWarehouse(active, overseasWarehouseId);
if (active != null) {
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
if (active != null && !containerNo.equals(active.getContainerNo())) {
throw conflict("Finish the current container before selecting another one");
}
if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
if (active == null && sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
throw conflict("Container is already signed or prepared by another account");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
bindReceivingWarehouse(containerPlans, receivingWarehouse.warehouseId());
if (active != null) {
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
}
LocalDateTime now = LocalDateTime.now();
ReceivingContainerSession session = new ReceivingContainerSession();
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
session.setSourceContainerBookingId(readiness.sourceContainerBookingId());
session.setContainerFactHash(readiness.factHash());
session.setOverseasWarehouseId(overseasWarehouseId);
session.setOverseasWarehouseCode(receivingWarehouse.warehouseCode());
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
@@ -412,7 +412,7 @@ public class ReceivingContainerService {
ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts,
overseasWarehouseId);
if (value == null || !value.operable()) {
throw conflict(value == null ? "Container is assigned to another overseas warehouse" : value.blockReason());
throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason());
}
return value;
}
@@ -444,8 +444,13 @@ public class ReceivingContainerService {
try { assignedWarehouses.add(Long.valueOf(value)); }
catch (NumberFormatException ignored) { assignedWarehouses.add(Long.MIN_VALUE); }
});
if (assignedWarehouses.size() > 1) throw conflict("Container plans have inconsistent receiving warehouse assignments");
if (!assignedWarehouses.isEmpty() && !assignedWarehouses.contains(overseasWarehouseId)) return null;
if (assignedWarehouses.size() > 1) return null;
if (assignedWarehouses.size() != 1 || !assignedWarehouses.contains(overseasWarehouseId)) return null;
Set<String> warehouseCodes = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseCode)
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
Set<String> warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName)
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
boolean warehouseSnapshotConsistent = warehouseCodes.size() == 1 && warehouseNames.size() == 1;
Set<String> customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet());
String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null);
int totalOrders = orderFacts.size();
@@ -464,10 +469,11 @@ public class ReceivingContainerService {
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
String customerId = customers.size() == 1 ? customers.iterator().next() : null;
boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady;
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
int pending = Math.max(0, totalOrders - plannedOrders);
String blockReason = operable ? null : (!allPlansReady
? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码"
String blockReason = operable ? null : (!warehouseSnapshotConsistent
? "柜内接柜仓业务快照不一致"
: !allPlansReady ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码"
: "柜号尚未达到海外收货可操作节点");
LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt)
.filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null);
@@ -488,7 +494,9 @@ public class ReceivingContainerService {
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(),
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
return new ContainerReadiness(sourceContainerBookingId, containerNo, totalOrders, inboundCodes,
return new ContainerReadiness(sourceContainerBookingId, containerNo,
warehouseCodes.size() == 1 ? warehouseCodes.iterator().next() : null,
warehouseNames.size() == 1 ? warehouseNames.iterator().next() : null, totalOrders, inboundCodes,
totalOrders - inboundCodes, planCount, plannedOrders, pending, allPlansReady, allPlansCompleted, shippingReady, operable,
blockReason, arrival, factHash, totals);
}
@@ -498,6 +506,7 @@ public class ReceivingContainerService {
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
Totals totals = value.totals();
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
value.overseasWarehouseCode(), value.overseasWarehouseName(),
active ? current.getStatus() : "WAIT_SIGN", value.totalOrderCount(), value.inboundCodeCount(),
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
@@ -663,7 +672,7 @@ public class ReceivingContainerService {
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
}
@@ -864,6 +873,7 @@ public class ReceivingContainerService {
String containerNo) {
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
value.setOverseasWarehouseId(warehouse.warehouseId());
value.setOverseasWarehouseCode(warehouse.warehouseCode());
value.setOverseasWarehouseName(warehouse.warehouseName());
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
return value;
@@ -881,7 +891,23 @@ public class ReceivingContainerService {
if (assigned.size() > 1) {
throw conflict("Container plans have inconsistent receiving warehouse assignments");
}
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
return assigned.isEmpty() || assigned.size() == 1 && assigned.contains(overseasWarehouseId);
}
private void bindReceivingWarehouse(List<InboundPlan> containerPlans, long overseasWarehouseId) {
for (InboundPlan plan : containerPlans) {
if (plan.getOverseasWarehouseId() != null) {
if (!Objects.equals(plan.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
}
continue;
}
plan.setOverseasWarehouseId(overseasWarehouseId);
plan.setUpdatedAt(LocalDateTime.now());
if (plans.updateById(plan) != 1) {
throw conflict("Receiving warehouse assignment changed concurrently");
}
}
}
private void requireReceivingAvailable(String customerId, String containerNo) {
@@ -890,30 +916,6 @@ public class ReceivingContainerService {
}
}
private void claimReceivingWarehouse(String customerId, List<InboundPlan> containerPlans,
WarehouseResourceGateway.WarehouseReference warehouse) {
long overseasWarehouseId = warehouse.warehouseId();
String warehouseId = String.valueOf(overseasWarehouseId);
for (InboundPlan plan : containerPlans) {
int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId);
InboundPlan actual = plans.selectById(plan.getId());
if ((claimed == 0 && actual == null) || actual == null
|| !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Container was claimed by another overseas warehouse");
}
plan.setOverseasWarehouseId(overseasWarehouseId);
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId,
warehouse.warehouseName());
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) {
throw conflict("Inbound order was claimed by another overseas warehouse");
}
}
}
}
private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) {
if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Current container belongs to another overseas warehouse");
@@ -990,6 +992,7 @@ public class ReceivingContainerService {
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { }
private record ContainerReadiness(String sourceContainerBookingId, String containerNo,
String overseasWarehouseCode, String overseasWarehouseName,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason,
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
@@ -23,6 +24,11 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -31,6 +37,10 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -50,6 +60,65 @@ public class InboundPlanService {
@Resource private InboundPlanQueryMapper queryMapper;
@Resource private InventoryBalanceMapper inventoryBalanceMapper;
@Resource private DropshipOrderService dropshipOrders;
@Resource private WarehouseResourceGateway warehouseGateway;
@Resource private LastlegOrderCommandRecordMapper commandMapper;
@Resource private ObjectMapper objectMapper;
@Transactional
public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey,
String operatorId) {
String customerId = trimToNull(request.customerId());
if (customerId == null || "0".equals(customerId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台人工计划必须指定客户");
}
String key = trimToNull(idempotencyKey);
if (key == null || key.length() > 128) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必须为1至128个字符");
}
List<InboundPlanOrderMapper.ContainerWarehouseRef> containerFacts =
orderMapper.lockContainerWarehouseFacts(customerId, request.orderId());
if (containerFacts == null || containerFacts.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜");
}
String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream()
.map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList());
List<SourceOrderSnapshot> snapshots = orderMapper.lockOrder(customerId, request.orderId());
if (snapshots == null || snapshots.size() != 1) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在");
}
SourceOrderSnapshot order = snapshots.get(0);
String requestHash = sha256(request);
LastlegOrderCommandRecord replay = commandMapper.find(customerId, request.orderId(), "MANUAL_PLAN", operatorId, key);
if (replay != null) {
if (!MessageDigest.isEqual(replay.getRequestHash().getBytes(StandardCharsets.UTF_8),
requestHash.getBytes(StandardCharsets.UTF_8))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一幂等键不能用于不同请求");
}
return detailByPlanNo(customerId, replay.getResultRef());
}
if (planMapper.findBySourceOrder(customerId, request.orderId()) != null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单已生成入库计划");
}
if (order.version() != request.expectedOrderVersion()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试");
}
if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划");
if (!Objects.equals(containerWarehouseId, trimToNull(order.overseasWarehouseId()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜接柜仓冻结事实已变化,请刷新后重试");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
requiredReceivingWarehouse(containerWarehouseId);
validateOrders(snapshots);
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
request, receivingWarehouse);
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId());
command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key);
command.setRequestHash(requestHash); command.setResultRef(response.planNo());
command.setResultJson(json(response)); command.setResultVersion(request.expectedOrderVersion() + 1);
command.setCreatedAt(LocalDateTime.now()); commandMapper.insert(command);
return response;
}
@Transactional(readOnly = true)
public PageResult<InboundPlanListResponse> page(String customerId, InboundPlanRequests.Query query) {
@@ -112,12 +181,13 @@ public class InboundPlanService {
return detail(customerId, existing);
}
validateOrders(orders);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null);
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets) {
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse) {
String containerNo = requiredContainerNo(orders.get(0).containerNo());
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
@@ -126,7 +196,23 @@ public class InboundPlanService {
plan.setCustomerId(customerId);
plan.setSourceOrderId(selectedOrderId);
plan.setContainerNo(containerNo);
String receivingWarehouseId = trimToNull(orders.get(0).overseasWarehouseId());
if (receivingWarehouseId == null || orders.stream().anyMatch(order ->
!Objects.equals(receivingWarehouseId, trimToNull(order.overseasWarehouseId())))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配或存在不一致");
}
try { plan.setOverseasWarehouseId(Long.parseLong(receivingWarehouseId)); }
catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception);
}
plan.setStatus("PENDING_ASSIGNMENT");
plan.setPlannedArrivalDate(manual == null ? null : manual.plannedArrivalDate());
plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase());
plan.setAssignedFlag(manual != null && manual.assigned());
plan.setMergeRequired(manual != null && manual.mergeRequired());
plan.setUrgentFlag(manual != null && manual.urgent());
plan.setCreationMode(manual == null ? "AUTO" : "MANUAL");
plan.setPlanRemark(manual == null ? null : trimToNull(manual.remark()));
plan.setLineCount(orders.size());
plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
@@ -136,16 +222,20 @@ public class InboundPlanService {
Map<Long, SourceOrderSnapshot> orderById = orders.stream()
.collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value));
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders);
PalletAllocator.Result allocation = wholePallets == null ? PalletAllocator.allocate(orders.stream()
PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream()
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount(), order.recommendedPalletCount())).toList()) : null;
if (wholePallets != null) validateWholePallets(orders, wholePallets);
plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.size());
plan.setPalletCount(manual != null ? manual.palletCount()
: wholePallets == null ? allocation.pallets().size() : wholePallets.size());
planMapper.insert(plan);
lineByOrder.values().forEach(lineMapper::insert);
Map<Long, Integer> palletCounts;
if (wholePallets == null) {
if (manual != null) {
createManualPallets(plan, orders.get(0), lineByOrder.get(orders.get(0).orderId()), manual);
palletCounts = Map.of(orders.get(0).orderId(), manual.palletCount());
} else if (wholePallets == null) {
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation,
orderById, sourcePalletNos == null ? List.of() : sourcePalletNos);
createAllocations(plan, allocation, lineByOrder, persistedPallets);
@@ -162,15 +252,19 @@ public class InboundPlanService {
InboundPlanLine line = lineByOrder.get(order.orderId());
line.setPlannedPalletCount(count);
lineMapper.updateById(line);
if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) {
int updated = manual == null
? orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now)
: orderMapper.markPlannedManual(customerId, order.orderId(), plan.getPlanNo(), count,
manual.mergeRequired(), manual.expectedOrderVersion(), now);
if (updated != 1) {
throw new IllegalStateException("Failed to update order " + order.orderId());
}
inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(),
plan.getPlanNo(), now);
inventoryBalanceMapper.bindInboundPlan(customerId, receivingWarehouseId, order.orderId(),
order.inboundCode(), plan.getPlanNo(), now);
orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(),
"Inbound order converted to inbound plan " + plan.getPlanNo(), now);
}
return detail(customerId, plan);
return detail(customerId, plan, receivingWarehouse);
}
/** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */
@@ -191,26 +285,24 @@ public class InboundPlanService {
List<InboundPlan> existingPlans = refs.stream()
.map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId()))
.toList();
long existingCount = existingPlans.stream().filter(Objects::nonNull).count();
if (existingCount == refs.size()) {
List<InboundPlanResponse> replay = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
requirePlanContainer(existingPlans.get(index), containerNo);
replay.add(detail(refs.get(index).customerId(), existingPlans.get(index)));
List<String> warehouseIds = new ArrayList<>(refs.size());
List<Integer> pendingIndexes = new ArrayList<>();
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>();
List<List<WholePalletInput>> wholePallets = new ArrayList<>();
for (int index = 0; index < refs.size(); index++) {
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
InboundPlan existing = existingPlans.get(index);
if (existing != null) {
requireValidExistingPlan(existing, containerNo);
InboundPlanOrderMapper.ReceivingWarehouseSnapshot warehouse =
orderMapper.findReceivingWarehouse(ref.customerId(), ref.orderId());
warehouseIds.add(warehouse == null ? null : warehouse.overseasWarehouseId());
continue;
}
if (!("PENDING_ASSIGNMENT".equals(ref.currentStatus())
|| "DESTINATION_ASSIGNED".equals(ref.currentStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单");
}
return List.copyOf(replay);
}
if (existingCount != 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜仅部分尾程订单已有入库计划");
}
if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus())
|| "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单");
}
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(refs.size());
List<List<WholePalletInput>> wholePallets = new ArrayList<>(refs.size());
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(ref.customerId(), ref.orderId());
if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在");
@@ -220,7 +312,9 @@ public class InboundPlanService {
if (!Objects.equals(containerNo, currentContainerNo)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致");
}
pendingIndexes.add(index);
snapshots.add(orders);
warehouseIds.addAll(orders.stream().map(SourceOrderSnapshot::overseasWarehouseId).toList());
List<WholePalletInput> sourcePallets = "ONE_CLICK_DROPSHIP".equals(ref.orderType())
? dropshipOrders.palletSnapshots(ref.customerId(), ref.orderId()).stream()
.map(this::wholePallet).toList()
@@ -234,20 +328,64 @@ public class InboundPlanService {
}
wholePallets.add(sourcePallets);
}
requireSingleWarehouseId(warehouseIds);
List<InboundPlanResponse> created = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(),
wholePallets.get(index)));
Map<Integer, InboundPlanResponse> created = new HashMap<>();
for (int pendingIndex = 0; pendingIndex < pendingIndexes.size(); pendingIndex++) {
int refIndex = pendingIndexes.get(pendingIndex);
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(refIndex);
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), List.of(),
wholePallets.get(pendingIndex), null, null));
}
return List.copyOf(created);
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
InboundPlan existing = existingPlans.get(index);
result.add(existing == null ? created.get(index) : detail(refs.get(index).customerId(), existing));
}
return List.copyOf(result);
}
private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) {
return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount()));
}
private void createManualPallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line,
ManualInboundPlanRequest request) {
if (order.packageCount() > 0 && request.palletCount() > order.packageCount()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "人工计划托盘数不能大于订单箱数");
}
BigDecimal remainingVolume = volume(order);
int remainingPackages = order.packageCount();
for (int index = 0; index < request.palletCount(); index++) {
int slots = request.palletCount() - index;
int packages = slots == 1 ? remainingPackages : remainingPackages / slots;
BigDecimal palletVolume = slots == 1 ? remainingVolume
: remainingVolume.divide(BigDecimal.valueOf(slots), 4, java.math.RoundingMode.HALF_UP);
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", index + 1));
pallet.setPalletType(request.palletType().trim().toUpperCase());
pallet.setDestinationWarehouseId(order.destinationWarehouseId());
pallet.setDestinationWarehouseCode(order.destinationWarehouseCode());
pallet.setDestinationWarehouseName(order.destinationWarehouseName());
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume);
pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED");
pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId());
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId());
allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(packages);
allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0);
allocationMapper.insert(allocation);
remainingPackages -= packages; remainingVolume = remainingVolume.subtract(palletVolume);
}
if (remainingPackages != 0 || remainingVolume.signum() != 0) {
throw new IllegalStateException("人工计划托盘分配未保持箱数或体积守恒");
}
}
private String requiredContainerNo(String value) {
try {
return ContainerNumbers.requireCanonical(value);
@@ -264,6 +402,13 @@ public class InboundPlanService {
}
}
private void requireValidExistingPlan(InboundPlan plan, String containerNo) {
requirePlanContainer(plan, containerNo);
if (trimToNull(plan.getPlanNo()) == null || trimToNull(plan.getStatus()) == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在无效入库计划,不能继续按柜生成");
}
}
private void validateWholePallets(List<SourceOrderSnapshot> orders, List<WholePalletInput> pallets) {
if (orders.size() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
@@ -288,7 +433,8 @@ public class InboundPlanService {
if (palletVolume.compareTo(remainingVolume) > 0) palletVolume = remainingVolume;
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
pallet.setPalletNo(source.palletNo().trim()); pallet.setDestinationWarehouseId(order.destinationWarehouseId());
pallet.setPalletNo(source.palletNo().trim()); pallet.setSourcePalletNo(source.palletNo().trim());
pallet.setDestinationWarehouseId(order.destinationWarehouseId());
pallet.setDestinationWarehouseCode(order.destinationWarehouseCode());
pallet.setDestinationWarehouseName(order.destinationWarehouseName());
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume);
@@ -385,7 +531,7 @@ public class InboundPlanService {
plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan);
return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(),
pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(),
pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(),
pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getPalletType(), pallet.getAssignmentStatus(),
null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount());
}
@@ -428,6 +574,7 @@ public class InboundPlanService {
String sourcePalletNo = palletIndex < sourcePalletNos.size() ? sourcePalletNos.get(palletIndex) : null;
pallet.setPalletNo(sourcePalletNo == null || sourcePalletNo.isBlank()
? plan.getPlanNo() + "-P" + String.format("%03d", palletIndex + 1) : sourcePalletNo.trim());
pallet.setSourcePalletNo(sourcePalletNo == null || sourcePalletNo.isBlank() ? null : sourcePalletNo.trim());
pallet.setDestinationWarehouseId(sample.destinationWarehouseId());
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
@@ -492,9 +639,21 @@ public class InboundPlanService {
}
private InboundPlanResponse detail(String customerId, InboundPlan plan) {
return detail(customerId, plan, null);
}
private InboundPlanResponse detail(String customerId, InboundPlan plan,
WarehouseResourceGateway.WarehouseReference resolvedWarehouse) {
if (customerId != null && !customerId.equals(plan.getCustomerId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
InboundPlanOrderMapper.ReceivingWarehouseSnapshot receivingWarehouse = orderMapper.findReceivingWarehouse(
plan.getCustomerId(), plan.getSourceOrderId());
if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单缺少接柜仓预分配事实");
}
WarehouseResourceGateway.WarehouseReference authoritativeWarehouse = resolvedWarehouse == null
? requiredReceivingWarehouse(receivingWarehouse.overseasWarehouseId()) : resolvedWarehouse;
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId));
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
@@ -504,8 +663,10 @@ public class InboundPlanService {
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
plan.getContainerNo(),
plan.getContainerNo(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(),
allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(),
lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(),
line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(),
line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(),
@@ -513,12 +674,38 @@ public class InboundPlanService {
pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(),
pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(),
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(),
pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
pallet.getPalletType(), pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
pallet.getActualPackageCount())).toList(),
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
plan.getVersion() == null ? 0 : plan.getVersion());
plan.getVersion() == null ? 0 : plan.getVersion(), plan.getPlannedArrivalDate(), plan.getPalletType(),
Boolean.TRUE.equals(plan.getAssignedFlag()), Boolean.TRUE.equals(plan.getMergeRequired()),
Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark());
}
private WarehouseResourceGateway.WarehouseReference requiredReceivingWarehouse(String warehouseId) {
String stableId = trimToNull(warehouseId);
if (stableId == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配");
}
try {
return warehouseGateway.requireActiveSharedWarehouse(Long.parseLong(stableId));
} catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓ID格式无效", exception);
}
}
private String requireSingleWarehouseId(List<String> warehouseIds) {
List<String> normalized = warehouseIds.stream().map(InboundPlanService::trimToNull).toList();
if (normalized.stream().anyMatch(Objects::isNull)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在未分配接柜仓的尾程订单");
}
java.util.Set<String> distinct = new java.util.HashSet<>(normalized);
if (distinct.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单接柜仓不一致");
}
return distinct.iterator().next();
}
private BigDecimal volume(SourceOrderSnapshot order) {
@@ -534,4 +721,18 @@ public class InboundPlanService {
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String sha256(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsBytes(value)));
} catch (NoSuchAlgorithmException | JsonProcessingException exception) {
throw new IllegalStateException("无法生成命令摘要", exception);
}
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); }
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inventory.command.dto;
import java.util.List;
public record ReturnedInventoryRestoreRequest(
String exceptionNo,
String outboundNo,
String overseasWarehouseId,
Long inboundOrderId,
List<SourceItem> sources,
List<Receipt> receipts,
String idempotencyKey,
List<String> evidenceFileIds
) {
public record SourceItem(Long outboundPalletItemId, Long inventoryPositionId,
String skuCode, long maxReturnPackages) { }
public record Receipt(Long outboundPalletItemId, Long inventoryPositionId, long receivedPackages,
Long targetPalletId, String targetPalletNo,
Long targetLocationId, String targetLocationCode) { }
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inventory.command.dto;
import java.time.LocalDateTime;
import java.util.List;
public record ReturnedInventoryRestoreResponse(
long adjustmentId,
List<Long> ledgerIds,
long beforePackages,
long restoredPackages,
long afterPackages,
boolean replayed,
LocalDateTime completedAt
) {
public ReturnedInventoryRestoreResponse asReplay() {
return new ReturnedInventoryRestoreResponse(adjustmentId, ledgerIds, beforePackages,
restoredPackages, afterPackages, true, completedAt);
}
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inventory.command.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -32,6 +33,33 @@ public interface InventoryCommandMapper {
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND inbound_order_id=#{inboundOrderId} ORDER BY id FOR UPDATE")
List<InventoryBalance> lockInboundBalances(@Param("customerId") String customerId,
@Param("inboundOrderId") Long inboundOrderId);
@Select("SELECT COUNT(*) FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}")
long countPositions(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
@Delete("DELETE FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " +
"AND available_packages=0 AND hold_packages=0 AND allocated_packages=0 AND picked_packages=0 " +
"AND in_transit_packages=0 AND receiving_packages=0 AND procurement_packages=0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)")
int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger " +
"WHERE customer_id=#{customerId} AND position_id=#{positionId} AND outbound_order_no=#{outboundNo} " +
"AND sku_code=#{skuCode} AND business_type='SHIP' AND delta_packages<0")
long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId,
@Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode);
@Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " +
"WHERE customer_id=#{customerId} AND source_event_id=#{sourceIdentity} " +
"AND sku_code=#{skuCode} AND business_type='RETURN_STOCK_IN' AND delta_packages>0")
long restoredPackages(@Param("customerId") String customerId,
@Param("sourceIdentity") String sourceIdentity,
@Param("skuCode") String skuCode);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.hold_packages>0 ORDER BY p.id FOR UPDATE")
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.inventory.command.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
@@ -27,7 +29,10 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
public class InventoryCommandService {
@@ -57,6 +62,44 @@ public class InventoryCommandService {
});
}
@Transactional
public void rebindReceivingWarehouse(String customerId, Long inboundOrderId, long warehouseId,
String warehouseCode, String warehouseName,
String operatorId, String idempotencyKey) {
String targetId = Long.toString(warehouseId);
String targetCode = required(warehouseCode, "Receiving warehouse code is required");
String targetName = required(warehouseName, "Receiving warehouse name is required");
Long auditOperationId = IdWorker.getId();
for (InventoryBalance balance : commands.lockInboundBalances(customerId, inboundOrderId)) {
if (value(balance.getAvailablePackages()) != 0 || value(balance.getHoldPackages()) != 0
|| value(balance.getAllocatedPackages()) != 0 || value(balance.getPickedPackages()) != 0
|| value(balance.getReceivingPackages()) != 0 || value(balance.getProcurementPackages()) != 0
|| commands.countPositions(customerId, balance.getId()) != 0) {
throw conflict("Receiving warehouse cannot change after physical inventory exists");
}
InventoryBalance target = balances.lockDimension(customerId, targetId,
balance.getDestinationWarehouseCode(), balance.getInboundOrderId(), balance.getInboundCode());
if (target != null && !target.getId().equals(balance.getId())) {
throw conflict("Receiving warehouse change would create a duplicate inventory dimension");
}
String sourceId = balance.getOverseasWarehouseId();
if (Objects.equals(sourceId, targetId)
&& Objects.equals(balance.getOverseasWarehouseCode(), targetCode)
&& Objects.equals(balance.getOverseasWarehouseName(), targetName)) continue;
long packages = value(balance.getInTransitPackages());
balance.setOverseasWarehouseId(targetId);
balance.setOverseasWarehouseCode(targetCode);
balance.setOverseasWarehouseName(targetName);
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, auditOperationId, balance, null, balance.getSkuCode(),
"RECEIVING_WAREHOUSE_CHANGED", "IN_TRANSIT", "IN_TRANSIT",
packages, 0, packages, null, balance.getInboundOrderNo(), null, null,
idempotencyKey + ":BALANCE:" + balance.getId(),
"In-transit receiving warehouse changed from " + sourceId + " to " + targetId);
}
}
@Transactional
public InventoryCommandResponse stockIn(String customerId, String operatorId,
InventoryCommandRequests.StockIn request) {
@@ -126,6 +169,9 @@ public class InventoryCommandService {
transferred += moved;
pending -= moved;
if (targetDimension) continue;
if (commands.countPositions(customerId, source.getId()) != 0) {
throw conflict("In-transit source balance unexpectedly has physical positions");
}
touch(source);
balances.updateById(source);
ledger(customerId, operatorId, operationId, source, null, sku(request.skuCode()),
@@ -133,6 +179,9 @@ public class InventoryCommandService {
sourceBefore - moved, request.businessOrderNo(), request.inboundOrderNo(), null,
request.sourceEventId(), request.idempotencyKey() + ":TRANSIT:" + source.getId(),
"In-transit inventory transferred to overseas warehouse " + target.getOverseasWarehouseId());
if (empty(source) && commands.deleteEmptyBalance(customerId, source.getId()) != 1) {
throw conflict("Empty in-transit source balance changed concurrently");
}
}
return transferred;
}
@@ -200,6 +249,175 @@ public class InventoryCommandService {
});
}
@Transactional
public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId,
ReturnedInventoryRestoreRequest request) {
if (request == null) throw badRequest("Returned inventory restore request is required");
String exceptionNo = required(request.exceptionNo(), "Exception number is required");
String outboundNo = required(request.outboundNo(), "Outbound number is required");
String warehouseId = required(request.overseasWarehouseId(), "Overseas warehouse ID is required");
long warehouseResourceId;
try {
warehouseResourceId = Long.parseLong(warehouseId);
} catch (NumberFormatException exception) {
throw badRequest("Overseas warehouse ID must be numeric");
}
if (request.inboundOrderId() == null || request.inboundOrderId() <= 0) {
throw badRequest("Inbound order ID is required");
}
if (request.sources() == null || request.sources().isEmpty()) {
throw badRequest("Returned inventory sources are required");
}
if (request.receipts() == null || request.receipts().isEmpty()) {
throw badRequest("Returned inventory receipts are required");
}
if (request.evidenceFileIds() == null || request.evidenceFileIds().isEmpty()
|| request.evidenceFileIds().stream().anyMatch(value -> trim(value) == null)) {
throw badRequest("Return evidence file IDs are required");
}
Map<Long, ReturnedInventoryRestoreRequest.SourceItem> sources = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.SourceItem source : request.sources()) {
if (source == null || source.outboundPalletItemId() == null || source.outboundPalletItemId() <= 0
|| source.inventoryPositionId() == null || source.inventoryPositionId() <= 0
|| source.maxReturnPackages() <= 0) {
throw badRequest("A valid returned inventory source is required");
}
String sourceSku = required(source.skuCode(), "Returned inventory source SKU is required");
ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem(
source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages());
if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) {
throw badRequest("Returned inventory source must be unique");
}
}
Map<Long, Long> requestedBySource = new LinkedHashMap<>();
Map<String, Boolean> receiptTargets = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
ReturnedInventoryRestoreRequest.SourceItem source = receipt == null ? null
: sources.get(receipt.outboundPalletItemId());
if (source == null || !Objects.equals(source.inventoryPositionId(), receipt.inventoryPositionId())) {
throw badRequest("Return receipt must match an immutable outbound inventory source");
}
if (receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null || receipt.targetPalletId() <= 0
|| receipt.targetLocationId() == null || receipt.targetLocationId() <= 0) {
throw badRequest("A valid returned inventory receipt is required");
}
required(receipt.targetPalletNo(), "Target pallet number is required");
required(receipt.targetLocationCode(), "Target location code is required");
String receiptTarget = receipt.outboundPalletItemId() + ":" + receipt.targetPalletId();
if (receiptTargets.putIfAbsent(receiptTarget, Boolean.TRUE) != null) {
throw badRequest("Return receipt source and target pallet must be unique");
}
requestedBySource.merge(source.outboundPalletItemId(), receipt.receivedPackages(), Long::sum);
}
for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) {
long requested = requestedBySource.getOrDefault(source.outboundPalletItemId(), 0L);
if (requested <= 0 || requested > source.maxReturnPackages()) {
throw conflict("Returned packages exceed the approved source limit");
}
}
String normalizedKey = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey);
if (existing != null) return replayReturned(existing, requestHash);
InventoryOperation operation = new InventoryOperation();
operation.setId(IdWorker.getId());
operation.setCustomerId(customerId);
operation.setIdempotencyKey(normalizedKey);
operation.setOperationType("RETURN_STOCK_IN");
operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING");
operation.setCreatedAt(LocalDateTime.now());
operation.setUpdatedAt(operation.getCreatedAt());
try {
operations.insert(operation);
} catch (DuplicateKeyException exception) {
InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey);
if (concurrent != null) return replayReturned(concurrent, requestHash);
throw exception;
}
Map<Long, Locked> lockedSources = new LinkedHashMap<>();
Map<Long, Long> balanceBefore = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) {
Locked locked = locked(customerId, source.inventoryPositionId());
InventoryBalance balance = locked.balance();
if (!Objects.equals(balance.getInboundOrderId(), request.inboundOrderId())) {
throw conflict("Returned inventory source inbound order does not match");
}
if (!warehouseId.equals(trim(balance.getOverseasWarehouseId()))) {
throw conflict("Returned inventory source warehouse does not match");
}
long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo,
source.skuCode());
long restored = commands.restoredPackages(customerId, returnSourceIdentity(source), source.skuCode());
long requested = requestedBySource.get(source.outboundPalletItemId());
if (shipped <= 0) throw conflict("Immutable outbound shipment source was not found");
if (restored + requested > shipped) {
throw conflict("Returned packages exceed the remaining shipped packages");
}
lockedSources.put(source.outboundPalletItemId(), locked);
balanceBefore.putIfAbsent(balance.getId(), value(balance.getAvailablePackages()));
}
long restoredPackages = 0;
List<Long> ledgerIds = new java.util.ArrayList<>();
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId());
Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId());
InventoryBalance balance = sourceLocked.balance();
WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet(
receipt.targetPalletId());
if (pallet == null || pallet.palletId() != receipt.targetPalletId()
|| pallet.warehouseId() != warehouseResourceId
|| pallet.locationId() != receipt.targetLocationId()
|| !required(receipt.targetPalletNo(), "Target pallet number is required").equals(pallet.palletNo())) {
throw conflict("Target pallet does not match the returned inventory warehouse and location");
}
WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation(
warehouseResourceId, receipt.targetLocationId());
if (location == null || location.locationId() != receipt.targetLocationId()
|| !required(receipt.targetLocationCode(), "Target location code is required")
.equals(location.locationCode())) {
throw conflict("Target location does not match the returned inventory warehouse");
}
InventoryPosition target = returnedPosition(customerId, balance, exceptionNo, receipt, location);
long before = value(target.getAvailablePackages());
target.setAvailablePackages(before + receipt.receivedPackages());
target.setStatus("OCCUPIED");
target.setVersion(value(target.getVersion()) + 1);
target.setUpdatedAt(LocalDateTime.now());
positions.updateById(target);
balance.setAvailablePackages(value(balance.getAvailablePackages()) + receipt.receivedPackages());
touch(balance);
balances.updateById(balance);
InventoryLedger ledger = ledger(customerId, operatorId, operation.getId(), balance, target,
source.skuCode(), "RETURN_STOCK_IN", "SHIPPED", "AVAILABLE", before,
receipt.receivedPackages(), before + receipt.receivedPackages(), exceptionNo,
balance.getInboundOrderNo(), outboundNo, returnSourceIdentity(source),
normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(),
"Exception return received with evidence " + request.evidenceFileIds().size());
ledgerIds.add(ledger.getId());
restoredPackages += receipt.receivedPackages();
}
long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum();
long afterPackages = balanceBefore.keySet().stream()
.mapToLong(balanceId -> value(lockedSources.values().stream()
.map(Locked::balance).filter(balance -> balance.getId().equals(balanceId)).findFirst().orElseThrow()
.getAvailablePackages())).sum();
ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(),
List.copyOf(ledgerIds), beforePackages, restoredPackages, afterPackages, false, LocalDateTime.now());
operation.setStatus("COMPLETED");
operation.setResultJson(write(response));
operation.setUpdatedAt(response.completedAt());
operations.updateById(operation);
return response;
}
@Transactional
public InventoryCommandResponse adjust(String customerId, String operatorId,
InventoryCommandRequests.Adjustment request) {
@@ -287,6 +505,9 @@ public class InventoryCommandService {
String inboundCode, String requestedSkuCode, String productNameZh,
String productNameEn) {
String frozenCustomerName = required(customerName, "Customer name snapshot is required");
String frozenWarehouseId = required(overseasWarehouseId, "Overseas warehouse ID snapshot is required");
String frozenWarehouseCode = required(overseasWarehouseCode, "Overseas warehouse code snapshot is required");
String frozenWarehouseName = required(overseasWarehouseName, "Overseas warehouse name snapshot is required");
String skuCode = sku(requestedSkuCode);
String destinationId = trim(destinationWarehouseId);
String destinationCode = trim(destinationWarehouseCode);
@@ -294,10 +515,11 @@ public class InventoryCommandService {
throw badRequest("Destination warehouse ID or code is required");
}
if (destinationCode == null) destinationCode = "";
InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId,
InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId,
destinationCode, inboundOrderId, inboundCode);
if (balance != null) {
requireCustomerSnapshot(balance, frozenCustomerName);
requireWarehouseSnapshot(balance, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName);
mergeSkuSnapshot(balance, skuCode);
return balance;
}
@@ -305,9 +527,9 @@ public class InventoryCommandService {
balance.setId(IdWorker.getId());
balance.setCustomerId(customerId);
balance.setCustomerName(frozenCustomerName);
balance.setOverseasWarehouseId(overseasWarehouseId.trim());
balance.setOverseasWarehouseCode(trim(overseasWarehouseCode));
balance.setOverseasWarehouseName(trim(overseasWarehouseName));
balance.setOverseasWarehouseId(frozenWarehouseId);
balance.setOverseasWarehouseCode(frozenWarehouseCode);
balance.setOverseasWarehouseName(frozenWarehouseName);
balance.setDestinationWarehouseId(destinationId);
balance.setDestinationWarehouseCode(destinationCode);
balance.setDestinationWarehouseName(trim(destinationWarehouseName));
@@ -325,10 +547,11 @@ public class InventoryCommandService {
balances.insert(balance);
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId,
InventoryBalance concurrent = balances.lockDimension(customerId, frozenWarehouseId,
destinationCode, inboundOrderId, inboundCode);
if (concurrent != null) {
requireCustomerSnapshot(concurrent, frozenCustomerName);
requireWarehouseSnapshot(concurrent, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName);
mergeSkuSnapshot(concurrent, skuCode);
return concurrent;
}
@@ -433,7 +656,63 @@ public class InventoryCommandService {
}
}
private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
private InventoryPosition returnedPosition(String customerId, InventoryBalance balance, String exceptionNo,
ReturnedInventoryRestoreRequest.Receipt receipt,
WarehouseResourceGateway.WarehouseLocationReference location) {
String batchNo = "RETURN:" + exceptionNo;
InventoryPosition position = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo);
if (position != null) {
if (!Objects.equals(position.getWarehouseLocationId(), location.locationId())
|| !Objects.equals(position.getWarehouseLocationCode(), location.locationCode())
|| !Objects.equals(position.getPalletNo(), receipt.targetPalletNo())) {
throw conflict("Returned inventory target position snapshot does not match");
}
return position;
}
position = new InventoryPosition();
position.setId(IdWorker.getId());
position.setCustomerId(customerId);
position.setBalanceId(balance.getId());
position.setOverseasWarehouseId(balance.getOverseasWarehouseId());
position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
position.setPalletId(receipt.targetPalletId());
position.setPalletNo(receipt.targetPalletNo().trim());
position.setBatchNo(batchNo);
position.setWarehouseLocationId(location.locationId());
position.setWarehouseLocationCode(location.locationCode());
position.setAvailablePackages(0L);
position.setHoldPackages(0L);
position.setAllocatedPackages(0L);
position.setPickedPackages(0L);
position.setReceivingPackages(0L);
position.setStatus("OCCUPIED");
position.setVersion(0L);
position.setCreatedAt(LocalDateTime.now());
position.setUpdatedAt(position.getCreatedAt());
positions.insert(position);
return position;
}
private ReturnedInventoryRestoreResponse replayReturned(InventoryOperation operation, String requestHash) {
if (!"RETURN_STOCK_IN".equals(operation.getOperationType())
|| !requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different inventory request");
}
if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) {
throw conflict("Inventory operation is still processing");
}
try {
return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay();
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read returned inventory idempotency result", exception);
}
}
private static String returnSourceIdentity(ReturnedInventoryRestoreRequest.SourceItem source) {
return "RETURN_SOURCE:" + source.outboundPalletItemId() + ":" + source.inventoryPositionId();
}
private InventoryLedger ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
InventoryPosition position, String skuCode, String businessType, String from, String to,
long beforePackages, long deltaPackages, long afterPackages,
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
@@ -474,6 +753,7 @@ public class InventoryCommandService {
ledger.setOccurredAt(LocalDateTime.now());
ledger.setCreatedAt(ledger.getOccurredAt());
ledgers.insert(ledger);
return ledger;
}
private static void mergeSkuSnapshot(InventoryBalance balance, String skuCode) {
@@ -505,6 +785,13 @@ public class InventoryCommandService {
balance.setProcurementPackages(0L);
}
private static boolean empty(InventoryBalance balance) {
return value(balance.getAvailablePackages()) == 0 && value(balance.getHoldPackages()) == 0
&& value(balance.getAllocatedPackages()) == 0 && value(balance.getPickedPackages()) == 0
&& value(balance.getInTransitPackages()) == 0 && value(balance.getReceivingPackages()) == 0
&& value(balance.getProcurementPackages()) == 0;
}
private static long positionPackages(InventoryPosition position) {
return value(position.getAvailablePackages()) + value(position.getHoldPackages())
+ value(position.getAllocatedPackages()) + value(position.getPickedPackages())
@@ -583,6 +870,15 @@ public class InventoryCommandService {
}
}
private static void requireWarehouseSnapshot(InventoryBalance balance, String requestedWarehouseId,
String requestedWarehouseCode, String requestedWarehouseName) {
if (!requestedWarehouseId.equals(trim(balance.getOverseasWarehouseId()))
|| !requestedWarehouseCode.equals(trim(balance.getOverseasWarehouseCode()))
|| !requestedWarehouseName.equals(trim(balance.getOverseasWarehouseName()))) {
throw conflict("Inventory balance overseas warehouse snapshot does not match");
}
}
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw badRequest(message);
@@ -35,8 +35,10 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Param("inboundCode") String inboundCode);
@Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}")
"WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}")
int bindInboundPlan(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode,
@Param("planNo") String planNo,
@@ -38,7 +38,7 @@ public class InventoryQueryController {
@GetMapping(BASE_PATH + "/summary")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
@Operation(summary = "Get inbound-order package inventory totals", description = "Zero-balance dimensions are excluded. Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -48,7 +48,7 @@ public class InventoryQueryController {
@GetMapping(BASE_PATH + "/balances")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Returns only non-zero dimensions with the frozen receiving overseas warehouse ID/code/name. Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -56,6 +56,16 @@ public class InventoryQueryController {
return ApiResponse.success(service.balances(platformScope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Get one inventory balance", description = "Returns one non-zero inventory balance by ID and optional customer scope. Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryBalanceView> balance(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long balanceId,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.balance(platformScope(user, customerId), balanceId));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
@@ -79,7 +89,7 @@ public class InventoryQueryController {
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Uses the same non-zero balance filter and frozen receiving warehouse snapshots as the page API. Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -14,7 +14,8 @@ import java.util.List;
@Mapper
public interface InventoryQueryMapper {
String BALANCE_FILTER = " WHERE 1=1" +
String BALANCE_FILTER = " WHERE (available_packages+hold_packages+allocated_packages+picked_packages+" +
"in_transit_packages+receiving_packages+procurement_packages)>0" +
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
@@ -37,7 +38,8 @@ public interface InventoryQueryMapper {
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages &gt; 0</when>" +
"</choose>";
String JOINED_BALANCE_FILTER = " WHERE 1=1" +
String JOINED_BALANCE_FILTER = " WHERE (b.available_packages+b.hold_packages+b.allocated_packages+" +
"b.picked_packages+b.in_transit_packages+b.receiving_packages+b.procurement_packages)>0" +
"<if test='customerId != null and customerId != \"\"'> AND b.customer_id=#{customerId}</if>" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND b.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
@@ -109,6 +111,17 @@ public interface InventoryQueryMapper {
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} LIMIT 1")
InventoryBalance findBalance(@Param("balanceId") long balanceId);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " +
"AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" +
"receiving_packages+procurement_packages)>0 LIMIT 1")
InventoryBalance ownedVisibleBalance(@Param("customerId") String customerId,
@Param("balanceId") long balanceId);
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} " +
"AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" +
"receiving_packages+procurement_packages)>0 LIMIT 1")
InventoryBalance visibleBalance(@Param("balanceId") long balanceId);
String LEDGER_FILTER = " WHERE 1=1" +
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
@@ -57,6 +57,16 @@ public class InventoryQueryService {
return page(records, query.page(), query.size(), total);
}
public InventoryBalanceView balance(String customerId, long balanceId) {
InventoryBalance balance = customerId == null
? mapper.visibleBalance(balanceId)
: mapper.ownedVisibleBalance(customerId, balanceId);
if (balance == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found");
}
return balanceView(balance);
}
public List<InventoryPositionView> positions(String customerId, long balanceId,
InventoryQueryRequests.PositionQuery query) {
validateStatus(query.inventoryStatus(), POSITION_STATUSES);
@@ -268,7 +268,7 @@ public class LocalLooseboardService {
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId()));
order.setCustomerId(source.getCustomerId());
order.setOrderType("LOCAL_LOOSEBOARD");
order.setInboundCode(source.getOrderNo());
order.setInboundCode(null);
order.setSourceRegion("OVERSEAS_LOCAL");
order.setSourceInboundId("LOCAL_LOOSEBOARD:" + source.getId());
order.setSourceOrderId(String.valueOf(source.getId()));
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LastlegOrderCommandRecordMapper extends BaseMapper<LastlegOrderCommandRecord> {
@Select("SELECT * FROM ll_order_command_record WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND action=#{action} AND operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
LastlegOrderCommandRecord find(@Param("customerId") String customerId, @Param("orderId") long orderId,
@Param("action") String action, @Param("operatorId") String operatorId,
@Param("key") String key);
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrderHoldHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LastlegOrderHoldHistoryMapper extends BaseMapper<LastlegOrderHoldHistory> { }
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LastlegOrderManualNodeHistoryMapper extends BaseMapper<LastlegOrderManualNodeHistory> { }
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface LastlegOrderManualNodeMapper extends BaseMapper<LastlegOrderManualNode> {
@Delete("DELETE FROM ll_order_manual_node WHERE customer_id=#{customerId} AND order_id=#{orderId}")
int deleteCurrent(@Param("customerId") String customerId, @Param("orderId") long orderId);
}
@@ -14,20 +14,27 @@ import java.util.List;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " +
"OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))";
String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " +
"ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id&lt;&gt;'' " +
"<if test='q.containerNo != null and q.containerNo != \"\"'>AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') </if>" +
"<if test='q.createdFrom != null'>AND o.created_at &gt;=#{q.createdFrom} </if>" +
"<if test='q.createdTo != null'>AND o.created_at &lt;=#{q.createdTo} </if>" +
"GROUP BY o.source_container_booking_id " +
"HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " +
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) " +
"&lt; COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) ";
"GROUP BY o.source_container_booking_id ";
String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
"THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_id,''))=1 THEN MAX(o.overseas_warehouse_id) ELSE NULL END AS overseasWarehouseId," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_code,''))=1 THEN MAX(o.overseas_warehouse_code) ELSE NULL END AS overseasWarehouseCode," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_name,''))=1 THEN MAX(o.overseas_warehouse_name) ELSE NULL END AS overseasWarehouseName," +
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)=1 THEN MAX(o.receiving_warehouse_version) ELSE -1 END AS assignmentVersion," +
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)=1 AND NOT EXISTS (SELECT 1 FROM ll_receiving_container_session s WHERE s.source_container_booking_id=o.source_container_booking_id) THEN TRUE ELSE FALSE END AS canChangeReceivingWarehouse," +
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)&lt;&gt;1 THEN '柜内接柜仓分配版本不一致' " +
"WHEN EXISTS (SELECT 1 FROM ll_receiving_container_session s WHERE s.source_container_booking_id=o.source_container_booking_id) THEN '海外PDA已建立接柜会话,接柜仓已永久锁定' ELSE NULL END AS receivingWarehouseLockReason," +
"COUNT(*) AS totalOrderCount,COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) AS inboundCodeCount," +
"SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END) AS missingInboundCodeCount," +
"COUNT(DISTINCT p.id) AS planCount," +
@@ -114,14 +121,52 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
List<String> lockContainerNumbers(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT COUNT(*) FROM ll_receiving_container_session WHERE source_container_booking_id=#{sourceContainerBookingId}")
long countReceivingSessions(@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_code=#{warehouseCode}," +
"overseas_warehouse_name=#{warehouseName},receiving_warehouse_version=receiving_warehouse_version+1,updated_at=NOW(3) " +
"WHERE source_container_booking_id=#{sourceContainerBookingId} AND receiving_warehouse_version=#{expectedVersion}")
int updateContainerReceivingWarehouse(@Param("sourceContainerBookingId") String sourceContainerBookingId,
@Param("warehouseId") String warehouseId, @Param("warehouseCode") String warehouseCode,
@Param("warehouseName") String warehouseName, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " +
"SET p.overseas_warehouse_id=#{warehouseId},p.version=p.version+1,p.updated_at=NOW(3) " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId}")
int updateContainerPlanReceivingWarehouse(@Param("sourceContainerBookingId") String sourceContainerBookingId,
@Param("warehouseId") long warehouseId);
@Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}")
long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE")
LastlegOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
@Update("UPDATE ll_order SET detained=#{held},hold_chargeable=#{chargeable}," +
"hold_reason=CASE WHEN #{held}=1 THEN #{reason} ELSE NULL END,version=version+1,updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{id} AND version=#{expectedVersion}")
int updateHold(@Param("customerId") String customerId, @Param("id") long id,
@Param("held") boolean held, @Param("chargeable") boolean chargeable,
@Param("reason") String reason, @Param("expectedVersion") int expectedVersion,
@Param("updatedAt") java.time.LocalDateTime updatedAt);
@Update("UPDATE ll_order SET manual_node_override=1,estimated_arrival_at=#{estimatedArrivalAt}," +
"actual_arrival_at=#{actualArrivalAt},estimated_warehouse_at=#{estimatedWarehouseAt}," +
"version=version+1,updated_at=#{updatedAt} WHERE customer_id=#{customerId} AND id=#{id} " +
"AND version=#{expectedVersion}")
int updateManualNodes(@Param("customerId") String customerId, @Param("id") long id,
@Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt,
@Param("actualArrivalAt") java.time.LocalDateTime actualArrivalAt,
@Param("estimatedWarehouseAt") java.time.LocalDateTime estimatedWarehouseAt,
@Param("expectedVersion") int expectedVersion,
@Param("updatedAt") java.time.LocalDateTime updatedAt);
@Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " +
"WHERE customer_id=#{customerId} AND id=#{id} " +
"WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " +
"AND package_count-shipped_package_count-picked_package_count >= #{packages}")
int increasePicked(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages);
@@ -130,18 +175,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," +
"picked_package_count=picked_package_count-#{packages}," +
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " +
"WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " +
"WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 AND picked_package_count >= #{packages} " +
"AND shipped_package_count+#{packages} <= package_count")
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_name=#{warehouseName}," +
"updated_at=NOW(3) WHERE id=#{orderId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId,
@Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag}," +
"pallet_merge_batch_id=CASE WHEN #{flag} IS NULL THEN NULL ELSE pallet_merge_batch_id END," +
"updated_at=#{updatedAt} " +
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingWarehouseOperationMapper extends BaseMapper<ReceivingWarehouseOperation> {
@Select("SELECT * FROM ll_receiving_warehouse_operation WHERE idempotency_key=#{key} LIMIT 1")
ReceivingWarehouseOperation findByKey(@Param("key") String key);
}
@@ -370,7 +370,7 @@ public class RelabelApplicationService implements WalletResultHandler {
order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next("RELABEL", warehouseId, order.getId()));
order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(relabelNo); order.setSourceRegion("CUSTOMER");
order.setInboundCode(null); order.setSourceRegion("CUSTOMER");
order.setSourceInboundId(relabelNo + ":" + warehouseId);
order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId);
order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0);
@@ -32,6 +32,7 @@ public final class OverseasPermissions {
public static final String OUTBOUND_CANCEL = "platform:overseas-warehouse:outbound:cancel";
public static final String EXCEPTION_ORDER_LIST = "platform:overseas-warehouse:exception-order:list";
public static final String EXCEPTION_ORDER_CREATE = "platform:overseas-warehouse:exception-order:create";
public static final String EXCEPTION_ORDER_PROCESS = "platform:overseas-warehouse:exception-order:process";
public static final String CUSTOMER_VISIT_LIST = "platform:overseas-warehouse:customer-visit:list";
public static final String CUSTOMER_VISIT_EDIT = "platform:overseas-warehouse:customer-visit:edit";
public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list";
@@ -38,11 +38,13 @@ public class InboundOrderDetainedOutboxService {
JsonNode payloadNode = objectMapper.valueToTree(payload);
String eventKey = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED + ':'
+ order.getSourceInboundId() + ':' + changeVersion;
String causationId = EventIdentifiers.isSupported(order.getSourceEventId())
? order.getSourceEventId() : null;
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt,
RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER",
order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(),
eventKey, order.getSourceEventId(), payloadNode,
eventKey, causationId, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox();
@@ -0,0 +1,240 @@
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
import com.baiyue.lastleg.entity.LastlegOrderHoldHistory;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class LastlegOrderCommandService {
private static final String HOLD = "HOLD";
private static final String MANUAL_NODES = "MANUAL_NODES";
private static final List<String> NODE_CODES = List.of(
"DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL",
"OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED");
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderCommandRecordMapper commands;
@Resource private LastlegOrderHoldHistoryMapper holdHistory;
@Resource private LastlegOrderManualNodeMapper manualNodes;
@Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Resource private LastlegOrderOperationLogMapper operationLogs;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ObjectMapper objectMapper;
@Transactional
public LastlegOrderCommandDtos.HoldResponse hold(String requestedCustomerId, long orderId,
String idempotencyKey,
LastlegOrderCommandDtos.HoldRequest request,
String operatorId, String operatorName) {
String key = key(idempotencyKey);
LastlegOrder order = lockOwned(requestedCustomerId, orderId);
String hash = hash(request);
LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, HOLD, operatorId, key);
if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.HoldResponse.class);
int version = version(order);
if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试");
boolean held = request.held();
if (Boolean.TRUE.equals(order.getDetained()) == held) {
throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态");
}
if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount())
|| text(order.getOutboundOrderNo()) != null)) {
throw conflict("尾程订单已进入出库流程,不能新增暂扣");
}
LocalDateTime now = LocalDateTime.now();
boolean chargeable = held && request.chargeable();
String reason = request.reason().trim();
if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) {
throw conflict("尾程订单版本已变化,请刷新后重试");
}
int nextVersion = version + 1;
LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse(
orderId, held, chargeable, held ? reason : null, nextVersion);
LastlegOrderHoldHistory history = new LastlegOrderHoldHistory();
history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId);
history.setHeld(held); history.setChargeable(chargeable); history.setReason(reason);
history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); history.setOperatorName(operatorName);
history.setIdempotencyKey(key); history.setCreatedAt(now); holdHistory.insert(history);
operation(order, held ? "暂扣" : "解除暂扣", operatorName,
(chargeable ? "计费;" : "不计费;") + reason, now);
saveCommand(order, HOLD, operatorId, key, hash, null, response, nextVersion, now);
order.setDetained(held);
order.setHoldChargeable(chargeable);
order.setHoldReason(held ? reason : null);
order.setVersion(nextVersion);
if (text(order.getSourceInboundId()) != null) {
detainedOutbox.publish(order, held, reason, operatorId, operatorName,
now.toInstant(ZoneOffset.UTC), history.getId());
}
return response;
}
@Transactional
public LastlegOrderCommandDtos.ManualNodesResponse saveManualNodes(String requestedCustomerId, long orderId,
String idempotencyKey,
LastlegOrderCommandDtos.ManualNodesRequest request,
String operatorId, String operatorName) {
String key = key(idempotencyKey);
LastlegOrder order = lockOwned(requestedCustomerId, orderId);
List<LastlegOrderCommandDtos.ManualNode> normalized = normalizeNodes(request.nodes());
String hash = hash(new LastlegOrderCommandDtos.ManualNodesRequest(request.expectedVersion(), normalized));
LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, MANUAL_NODES, operatorId, key);
if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.ManualNodesResponse.class);
int version = version(order);
if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试");
Map<String, LocalDateTime> values = new HashMap<>();
normalized.forEach(node -> values.put(node.nodeCode(), node.nodeAt()));
LocalDateTime now = LocalDateTime.now();
if (orders.updateManualNodes(order.getCustomerId(), orderId,
values.get("ESTIMATED_ARRIVAL"), values.get("ACTUAL_ARRIVAL_WAITING_UNLOAD"),
null, version, now) != 1) {
throw conflict("尾程订单版本已变化,请刷新后重试");
}
int nextVersion = version + 1;
manualNodes.deleteCurrent(order.getCustomerId(), orderId);
List<LastlegOrderCommandDtos.ManualNodeView> views = new ArrayList<>(normalized.size());
for (LastlegOrderCommandDtos.ManualNode node : normalized) {
LastlegOrderManualNode entity = new LastlegOrderManualNode();
entity.setId(IdWorker.getId()); entity.setCustomerId(order.getCustomerId()); entity.setOrderId(orderId);
entity.setNodeCode(node.nodeCode()); entity.setNodeAt(node.nodeAt()); entity.setSource("MANUAL");
entity.setOrderVersion(nextVersion); entity.setUpdatedBy(operatorId); entity.setUpdatedAt(now);
manualNodes.insert(entity);
views.add(new LastlegOrderCommandDtos.ManualNodeView(node.nodeCode(), node.nodeAt(), "MANUAL"));
}
LastlegOrderCommandDtos.ManualNodesResponse response = new LastlegOrderCommandDtos.ManualNodesResponse(
orderId, true, List.copyOf(views), nextVersion);
LastlegOrderManualNodeHistory history = new LastlegOrderManualNodeHistory();
history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId);
history.setNodesJson(json(views)); history.setOrderVersion(nextVersion); history.setOperatorId(operatorId);
history.setOperatorName(operatorName); history.setIdempotencyKey(key); history.setCreatedAt(now);
manualNodeHistory.insert(history);
operation(order, "人工计划节点", operatorName, "保存" + views.size() + "个人工节点", now);
saveCommand(order, MANUAL_NODES, operatorId, key, hash, null, response, nextVersion, now);
return response;
}
@Transactional(readOnly = true)
public LastlegOrderCommandDtos.ManualNodesResponse manualNodes(String requestedCustomerId, long orderId) {
LastlegOrder order = findOwned(requestedCustomerId, orderId);
List<LastlegOrderCommandDtos.ManualNodeView> nodes = manualNodes.selectList(
new LambdaQueryWrapper<LastlegOrderManualNode>()
.eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId())
.eq(LastlegOrderManualNode::getOrderId, orderId)
.orderByAsc(LastlegOrderManualNode::getNodeCode))
.stream().map(node -> new LastlegOrderCommandDtos.ManualNodeView(
node.getNodeCode(), node.getNodeAt(), node.getSource()))
.sorted(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))).toList();
return new LastlegOrderCommandDtos.ManualNodesResponse(orderId,
Boolean.TRUE.equals(order.getManualNodeOverride()), nodes, version(order));
}
private List<LastlegOrderCommandDtos.ManualNode> normalizeNodes(List<LastlegOrderCommandDtos.ManualNode> nodes) {
Set<String> seen = new HashSet<>();
boolean anyTime = false;
List<LastlegOrderCommandDtos.ManualNode> result = new ArrayList<>();
for (LastlegOrderCommandDtos.ManualNode node : nodes) {
String code = node.nodeCode().trim().toUpperCase();
if (!NODE_CODES.contains(code)) throw badRequest("不支持的人工展示节点:" + code);
if (!seen.add(code)) throw badRequest("人工节点不能重复:" + code);
result.add(new LastlegOrderCommandDtos.ManualNode(code, node.nodeAt()));
anyTime |= node.nodeAt() != null;
}
if (!seen.equals(Set.copyOf(NODE_CODES))) throw badRequest("必须提交完整8行人工时间快照");
if (!anyTime) throw badRequest("人工时间快照至少填写一个节点时间");
result.sort(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode())));
return List.copyOf(result);
}
private LastlegOrder lockOwned(String requestedCustomerId, long orderId) {
LastlegOrder found = findOwned(requestedCustomerId, orderId);
LastlegOrder locked = orders.lockById(found.getCustomerId(), orderId);
if (locked == null) throw notFound();
return locked;
}
private LastlegOrder findOwned(String requestedCustomerId, long orderId) {
LastlegOrder order = orders.selectById(orderId);
if (order == null || (text(requestedCustomerId) != null
&& !requestedCustomerId.trim().equals(order.getCustomerId()))) throw notFound();
return order;
}
private void operation(LastlegOrder order, String node, String operatorName, String result, LocalDateTime now) {
LastlegOrderOperationLog log = new LastlegOrderOperationLog();
log.setId(IdWorker.getId()); log.setCustomerId(order.getCustomerId()); log.setOrderId(order.getId());
log.setOperationNode(node); log.setOperatorName(operatorName); log.setResult(result); log.setCreatedAt(now);
operationLogs.insert(log);
}
private void saveCommand(LastlegOrder order, String action, String operatorId, String key, String hash,
String resultRef, Object response, int resultVersion, LocalDateTime now) {
LastlegOrderCommandRecord record = new LastlegOrderCommandRecord();
record.setId(IdWorker.getId()); record.setCustomerId(order.getCustomerId()); record.setOrderId(order.getId());
record.setAction(action); record.setOperatorId(operatorId); record.setIdempotencyKey(key);
record.setRequestHash(hash); record.setResultRef(resultRef); record.setResultJson(json(response));
record.setResultVersion(resultVersion); record.setCreatedAt(now); commands.insert(record);
}
private <T> T replay(LastlegOrderCommandRecord record, String hash, Class<T> type) {
if (!MessageDigest.isEqual(record.getRequestHash().getBytes(StandardCharsets.UTF_8),
hash.getBytes(StandardCharsets.UTF_8))) throw conflict("同一幂等键不能用于不同请求");
try { return objectMapper.readValue(record.getResultJson(), type); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取已保存命令结果", exception); }
}
private String hash(Object request) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsBytes(request)));
} catch (NoSuchAlgorithmException | JsonProcessingException exception) {
throw new IllegalStateException("无法生成命令摘要", exception);
}
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); }
}
private String key(String value) {
String key = text(value);
if (key == null || key.length() > 128) throw badRequest("Idempotency-Key 必须为1至128个字符");
return key;
}
private int version(LastlegOrder order) { return order.getVersion() == null ? 0 : order.getVersion(); }
private boolean positive(Integer value) { return value != null && value > 0; }
private String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); }
private ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
@@ -15,6 +16,9 @@ import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
@@ -23,6 +27,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -37,6 +43,7 @@ import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -50,16 +57,22 @@ public class LastlegOrderService {
public static final String INBOUND_PLANNED = "INBOUND_PLANNED";
public static final String PALLET_MERGE_REQUIRED = "REQUIRED";
public static final String PALLET_MERGED = "MERGED";
private static final List<String> MANUAL_NODE_CODES = List.of(
"DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL",
"OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED");
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private LastlegOrderFeeMapper feeMapper;
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
@Resource private LastlegOrderManualNodeMapper manualNodeMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
@Resource private InventoryCommandService inventory;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -80,7 +93,7 @@ public class LastlegOrderService {
long total = orderMapper.countContainerProgress(query);
List<ContainerPlanProgressSummary> records = total == 0 ? List.of()
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size())
.stream().map(this::requireFrozenContainer).toList();
.stream().map(this::containerSummaryForRead).toList();
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@@ -95,17 +108,131 @@ public class LastlegOrderService {
if (summary == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
}
return new ContainerPlanProgressDetail(requireFrozenContainer(summary),
return new ContainerPlanProgressDetail(containerSummaryForRead(summary),
orderMapper.findContainerProgressItems(bookingId));
}
private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) {
try {
ContainerNumbers.requireCanonical(summary.containerNo());
return summary;
} catch (IllegalArgumentException exception) {
@Transactional
public ContainerPlanProgressSummary changeReceivingWarehouse(String sourceContainerBookingId,
String idempotencyKey, LastlegOrderRequests.ChangeReceivingWarehouse request, String operatorName) {
String bookingId = normalizeBookingId(sourceContainerBookingId);
String key = normalizeIdempotencyKey(idempotencyKey);
String requestHash = hash(bookingId + "\n" + request.overseasWarehouseId() + "\n"
+ request.expectedVersion() + "\n" + Objects.toString(request.remark(), ""));
ReceivingWarehouseOperation replay = receivingWarehouseOperations.findByKey(key);
if (replay != null) {
if (!bookingId.equals(replay.getSourceContainerBookingId()) || !requestHash.equals(replay.getRequestHash())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Idempotency-Key 已用于其他接柜仓修改请求");
}
return requireFrozenContainer(orderMapper.findContainerProgress(bookingId));
}
List<LastlegOrder> orders = orderMapper.lockContainerOrders(bookingId);
if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
int version = requireSingleAssignmentVersion(orders);
if (version != request.expectedVersion()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓分配版本已变化,请刷新后重试");
}
if (orderMapper.countReceivingSessions(bookingId) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"来源柜真实集装箱号缺失、格式无效或存在漂移", exception);
"海外PDA已建立接柜会话,接柜仓已永久锁定");
}
WarehouseResourceGateway.WarehouseReference warehouse =
warehouseResourceGateway.requireActiveSharedWarehouse(request.overseasWarehouseId());
for (LastlegOrder order : orders) {
inventory.rebindReceivingWarehouse(order.getCustomerId(), order.getId(), warehouse.warehouseId(),
warehouse.warehouseCode(), warehouse.warehouseName(), operatorName, key);
}
int updated = orderMapper.updateContainerReceivingWarehouse(bookingId,
Long.toString(warehouse.warehouseId()), warehouse.warehouseCode(), warehouse.warehouseName(), version);
if (updated != orders.size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配发生并发变化");
}
orderMapper.updateContainerPlanReceivingWarehouse(bookingId, warehouse.warehouseId());
ReceivingWarehouseOperation operation = new ReceivingWarehouseOperation();
operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId);
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(version + 1); operation.setOperatorName(operatorName);
operation.setRemark(request.remark() == null ? null : request.remark().trim());
operation.setCreatedAt(LocalDateTime.now()); receivingWarehouseOperations.insert(operation);
return requireFrozenContainer(orderMapper.findContainerProgress(bookingId));
}
private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) {
if (summary == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
String issue = containerSnapshotIssue(summary);
if (issue != null) throw new ResponseStatusException(HttpStatus.CONFLICT, issue);
return summary;
}
private ContainerPlanProgressSummary containerSummaryForRead(ContainerPlanProgressSummary summary) {
String issue = containerSnapshotIssue(summary);
if (issue == null) return summary;
boolean invalidContainer = !hasCanonicalContainerNo(summary.containerNo());
return new ContainerPlanProgressSummary(summary.sourceContainerBookingId(), summary.containerNo(),
summary.overseasWarehouseId(), summary.overseasWarehouseCode(), summary.overseasWarehouseName(),
summary.assignmentVersion(), invalidContainer ? false : summary.canChangeReceivingWarehouse(),
invalidContainer ? issue : summary.receivingWarehouseLockReason(), summary.totalOrderCount(),
summary.inboundCodeCount(), summary.missingInboundCodeCount(), summary.planCount(),
summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.plannedArrivalAt(),
false, issue);
}
private String containerSnapshotIssue(ContainerPlanProgressSummary summary) {
if (!hasCanonicalContainerNo(summary.containerNo())) {
return "来源柜真实集装箱号缺失、格式无效或存在漂移";
}
if (!StringUtils.hasText(summary.overseasWarehouseId())
|| !StringUtils.hasText(summary.overseasWarehouseCode())
|| !StringUtils.hasText(summary.overseasWarehouseName()) || summary.assignmentVersion() < 0) {
return "柜内接柜仓业务快照不一致";
}
return null;
}
private boolean hasCanonicalContainerNo(String containerNo) {
try {
ContainerNumbers.requireCanonical(containerNo);
return true;
} catch (IllegalArgumentException exception) {
return false;
}
}
private String normalizeBookingId(String value) {
String result = value == null ? null : value.trim();
if (!StringUtils.hasText(result) || result.length() > 128) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空");
}
return result;
}
private String normalizeIdempotencyKey(String value) {
String result = value == null ? null : value.trim();
if (!StringUtils.hasText(result) || result.length() > 255) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必填且不得超过255字符");
}
return result;
}
private int requireSingleAssignmentVersion(List<LastlegOrder> orders) {
Integer version = orders.get(0).getReceivingWarehouseVersion();
if (version == null || orders.stream().anyMatch(order -> !Objects.equals(version,
order.getReceivingWarehouseVersion()) || !StringUtils.hasText(order.getOverseasWarehouseId())
|| !StringUtils.hasText(order.getOverseasWarehouseCode())
|| !StringUtils.hasText(order.getOverseasWarehouseName()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配事实不一致");
}
return version;
}
private String hash(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
@@ -127,7 +254,15 @@ public class LastlegOrderService {
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
.eq(LastlegOrderOperationLog::getOrderId, orderId)
.orderByAsc(LastlegOrderOperationLog::getCreatedAt));
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream()
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes = manualNodeMapper.selectList(
new LambdaQueryWrapper<LastlegOrderManualNode>()
.eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId())
.eq(LastlegOrderManualNode::getOrderId, orderId)
.orderByAsc(LastlegOrderManualNode::getId)).stream()
.map(value -> new LastlegOrderCommandDtos.ManualNodeView(
value.getNodeCode(), value.getNodeAt(), value.getSource()))
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -358,7 +493,7 @@ public class LastlegOrderService {
for (LastlegOrder order : orders) {
String line = String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
csv(order.getInboundCode()),
csv(displayInboundCode(order)),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
@@ -380,6 +515,7 @@ public class LastlegOrderService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status();
String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null;
return new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
@@ -389,11 +525,7 @@ public class LastlegOrderService {
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
.like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo())
.eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus)
.and(StringUtils.hasText(query.warehouse()), wrapper -> wrapper
.like(LastlegOrder::getOverseasWarehouseId, query.warehouse())
.or().like(LastlegOrder::getOverseasWarehouseName, query.warehouse())
.or().like(LastlegOrder::getTransferWarehouseCode, query.warehouse())
.or().like(LastlegOrder::getTransferWarehouseName, query.warehouse()))
.apply(StringUtils.hasText(warehouse), LastlegOrderMapper.WAREHOUSE_FILTER_SQL, warehouse)
.eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode,
query.transferWarehouseCode())
.eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal())
@@ -433,10 +565,12 @@ public class LastlegOrderService {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
o.getInboundCode(), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
@@ -445,6 +579,12 @@ public class LastlegOrderService {
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
private static String displayInboundCode(LastlegOrder order) {
String orderType = order.getOrderType();
return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType)
? null : order.getInboundCode();
}
private static String csv(Object value) {
if (value == null) return "";
String text = String.valueOf(value);
@@ -30,6 +30,9 @@ import com.fasterxml.jackson.databind.JsonNode;
@Service
public class LoadingCompletedConsumer {
static final String DEFAULT_RECEIVING_WAREHOUSE_ID = "193000000000200003";
static final String DEFAULT_RECEIVING_WAREHOUSE_CODE = "US-LAX-01";
static final String DEFAULT_RECEIVING_WAREHOUSE_NAME = "洛杉矶仓";
@Resource private LastlegEventInboxMapper inboxMapper;
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegInboundOrderMapper inboundOrderMapper;
@@ -240,7 +243,10 @@ public class LoadingCompletedConsumer {
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
order.setCustomerName(customerName);
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
order.setOverseasWarehouseId(DEFAULT_RECEIVING_WAREHOUSE_ID);
order.setOverseasWarehouseCode(DEFAULT_RECEIVING_WAREHOUSE_CODE);
order.setOverseasWarehouseName(DEFAULT_RECEIVING_WAREHOUSE_NAME);
order.setReceivingWarehouseVersion(0);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo);
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount());
@@ -335,8 +341,9 @@ public class LoadingCompletedConsumer {
FirstLegLoadingPlanCompletedV1.Item item = source.items().get(index);
if (item.packageCount() <= 0) continue;
inventory.registerInTransit(order.getCustomerId(), "SYSTEM",
new InventoryCommandRequests.InTransit(order.getCustomerName(), payload.warehouseId(), null,
payload.warehouseName(), item.destinationWarehouseId(), item.destinationWarehouseCode(),
new InventoryCommandRequests.InTransit(order.getCustomerName(), order.getOverseasWarehouseId(),
order.getOverseasWarehouseCode(), order.getOverseasWarehouseName(),
item.destinationWarehouseId(), item.destinationWarehouseCode(),
item.destinationWarehouseName(), order.getId(), inboundOrderNo, source.inboundCode(),
item.skuCode(), item.productNameZh(), item.productNameEn(), item.packageCount(),
order.getOrderNo(), event.eventId(),
+139 -13
View File
@@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发',
lx_code VARCHAR(128) NULL COMMENT '一件代发领星入库单代码',
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码',
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID',
@@ -58,8 +58,10 @@ CREATE TABLE IF NOT EXISTS ll_order (
customer_name VARCHAR(255) COMMENT '货主名称',
source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照',
source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照',
overseas_warehouse_id VARCHAR(128) COMMENT '海外实际收货仓 ID,由绑定仓库的PDA操作员认领柜号时冻结',
overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称',
overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓',
overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照',
overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照',
receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID',
transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码',
transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称',
@@ -68,6 +70,10 @@ CREATE TABLE IF NOT EXISTS ll_order (
container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态',
abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是',
hold_reason VARCHAR(500) NULL COMMENT '当前暂扣原因;解除后为空',
manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用单票人工计划节点覆盖:0否1是,启用后不再继承柜级节点',
version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数',
@@ -108,6 +114,14 @@ CREATE TABLE IF NOT EXISTS ll_order (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained'));
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable'));
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason'));
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override'));
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
CREATE TABLE IF NOT EXISTS ll_order_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
@@ -177,6 +191,61 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
CREATE TABLE IF NOT EXISTS ll_order_command_record (
id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES',
operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', result_ref VARCHAR(128) NULL COMMENT '结果业务引用',
result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', result_version INT NOT NULL COMMENT '命令成功后的订单版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key),
KEY idx_ll_order_command_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录';
CREATE TABLE IF NOT EXISTS ll_order_hold_history (
id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是',
chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因',
order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key),
KEY idx_ll_order_hold_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史';
CREATE TABLE IF NOT EXISTS ll_order_manual_node (
id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离',
node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL',
order_version INT NOT NULL COMMENT '最近编辑后的订单版本', updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID',
updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at)
) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实';
CREATE TABLE IF NOT EXISTS ll_order_manual_node_history (
id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照',
order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key),
KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史';
CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation (
id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID',
idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要',
result_version INT NOT NULL COMMENT '修改成功后的柜级分配版本',
operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称',
remark VARCHAR(500) NULL COMMENT '接柜仓修改备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_warehouse_idempotency(idempotency_key),
KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at)
) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录';
-- 派送单列表与详情快照、转派送费用及操作记录。
CREATE TABLE IF NOT EXISTS ll_delivery_order (
id BIGINT NOT NULL COMMENT '派送单主键ID',
@@ -213,6 +282,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
remark VARCHAR(1000) NULL COMMENT '派送备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
created_by VARCHAR(128) NULL COMMENT '创建人ID',
version BIGINT NOT NULL DEFAULT 0 COMMENT '派送单乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
@@ -349,7 +419,7 @@ ALTER TABLE ll_order
MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型',
MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空',
MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID',
@@ -357,8 +427,10 @@ ALTER TABLE ll_order
MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号',
MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID',
MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓 ID',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓',
MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照',
MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码',
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号',
MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态',
@@ -485,7 +557,14 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结',
overseas_warehouse_id BIGINT NOT NULL DEFAULT 193000000000200003 COMMENT '平台预分配的海外实际收货共享物理仓ID',
planned_arrival_date DATE NULL COMMENT '人工计划预计到仓日期',
pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型',
assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是',
merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是',
urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是',
creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工',
plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注',
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量',
@@ -537,7 +616,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)',
customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号',
pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码',
source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照',
pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
@@ -569,6 +650,20 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation (
ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id'));
PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
ALTER TABLE ll_inbound_plan
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)',
MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号',
@@ -599,7 +694,7 @@ ALTER TABLE ll_inbound_plan_pallet COMMENT='尾程入库计划托盘表:按目
ALTER TABLE ll_inbound_plan_pallet
MODIFY COLUMN id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)',
MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID',
MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号',
MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码',
MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
@@ -621,6 +716,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外仓实物托盘ID'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外仓库位ID'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''海外仓资源锁ID'''));
@@ -851,7 +948,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
@@ -1087,7 +1184,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号',
warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '海外仓实物托盘码',
source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照',
warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码',
batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数',
inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID',
@@ -1484,6 +1582,12 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)
) ENGINE=InnoDB COMMENT='换标货物冻结明细';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_relabel_status_history (
id BIGINT NOT NULL COMMENT '主键ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1827,9 +1931,12 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号',
handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称',
handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handling_result VARCHAR(2000) NULL COMMENT '异常处理结果',
evidence_file_ids_json JSON NULL COMMENT '异常处理证据文件ID列表', inventory_adjustment_id BIGINT NULL COMMENT '退回库存恢复操作ID',
handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称',
source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID',
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间',
version BIGINT NOT NULL DEFAULT 0 COMMENT '异常订单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no),
UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
@@ -1837,6 +1944,25 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
CREATE TABLE IF NOT EXISTS ll_exception_return_item (
id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID',
inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照',
sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id),
KEY idx_ll_exception_return_order(customer_id,exception_order_id)
) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细';
CREATE TABLE IF NOT EXISTS ll_exception_order_command (
id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作',
idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希',
result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key),
KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at)
) ENGINE=InnoDB COMMENT='异常处理命令幂等审计';
CREATE TABLE IF NOT EXISTS ll_customer_visit (
id BIGINT NOT NULL COMMENT '客户回访记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
@@ -3,6 +3,18 @@ info:
title: Lastleg Overseas Business API
version: 1.0.0
paths:
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
get:
summary: 查询单条库存余额详情
description: 仅平台且要求 lastleg:inventory:list;按 balanceId 直接查询非零库存余额,不依赖列表分页。可选 customerId 用于精确客户隔离,越权或不存在统一返回404。
security: [{ bearerAuth: [] }]
parameters:
- { in: path, name: balanceId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 } }
- { in: query, name: customerId, required: false, schema: { type: string } }
responses:
'200': { description: 单条库存余额, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryBalanceApiResponse' } } } }
'403': { description: 非平台身份或缺少库存查看权限 }
'404': { description: 库存余额不存在、已归零或不属于指定客户 }
/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}:
get:
summary: 按入仓码查询海外关系摘要
@@ -149,7 +161,7 @@ paths:
/api/platform/v1/last-leg/orders:
get:
summary: 分页查询尾程订单
description: 权限 platform:overseas-warehouse:order:list;平台管理员省略 customerId 时查询全部客户。
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -160,13 +172,13 @@ paths:
- { name: inboundCode, in: query, schema: { type: string } }
- { name: sourceOrderNo, in: query, schema: { type: string } }
- { name: containerNo, in: query, schema: { type: string } }
- { name: warehouse, in: query, schema: { type: string } }
- { name: warehouse, in: query, description: 按尾程订单冻结的海外接柜仓名称或编码进行大小写无关的包含匹配, schema: { type: string } }
- { name: transferWarehouseCode, in: query, schema: { type: string } }
- { name: abnormal, in: query, schema: { type: boolean } }
- { name: detained, in: query, schema: { type: boolean } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } }
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS;inboundCode 仅返回真实来源入仓码,无入仓关系的换标申请和本土散板为空 } }
/api/platform/v1/last-leg/orders/{id}:
get:
summary: 查询尾程订单详情
@@ -192,25 +204,46 @@ paths:
responses:
'200': { description: 新增后的费用明细, content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeApiResponse' } } } }
'404': { description: 尾程订单不存在或不属于操作客户 }
/api/platform/v1/last-leg/orders/{id}/detained:
/api/platform/v1/last-leg/orders/{id}/hold:
put:
summary: 设置或解除尾程订单暂扣
description: 权限 platform:overseas-warehouse:last-mile-order:edit;库存同步转入或释放暂扣,并通过跨区事件回写国内入仓单。
description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [detained, remark]
properties:
detained: { type: boolean }
remark: { type: string, minLength: 1, maxLength: 500 }
responses: { '200': { description: 暂扣状态已更新 } }
schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' }
responses:
'200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } }
'409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 }
/api/platform/v1/last-leg/orders/{id}/manual-nodes:
get:
summary: 查询尾程订单人工展示节点
description: 权限 platform:overseas-warehouse:last-mile-order:list;人工覆盖开启后返回完整且固定顺序的8行快照,行值允许为空。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
responses: { '200': { description: 完整人工展示节点快照 } }
put:
summary: 永久启用并保存尾程订单人工展示节点
description: 权限 platform:overseas-warehouse:last-mile-order:edit;提交完整8行快照且至少一个时间非空,使用订单版本和 Idempotency-Key;保存后 nodeSource 永久为 MANUAL,不回写柜节点,也不提供清除或恢复继承接口。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LastlegOrderManualNodesRequest' } } }
responses:
'200': { description: 更新后的完整8行人工展示节点快照 }
'409': { description: 订单版本或幂等键冲突 }
/api/platform/v1/last-leg/orders/{id}/pallet-merge-required:
put:
summary: 设置尾程订单合托标记
@@ -253,7 +286,7 @@ paths:
/api/platform/v1/last-leg/orders/container-summaries:
get:
summary: 分页查询柜级入库计划进度
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,仅返回仍有入仓码待生成有效独立计划的柜,全部入仓码均已生成有效独立计划的柜在分页前排除。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,返回全部稳定来源柜;全部入仓码均已生成有效独立计划的柜仍保留,供柜级接柜仓查询与修改,生成计划动作由 allPlansReady/pendingPlanCount 控制。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -283,7 +316,7 @@ paths:
/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:
get:
summary: 查询柜级入库计划进度详情
description: 权限 platform:overseas-warehouse:order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。
description: 权限 platform:overseas-warehouse:last-mile-order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。
security: [{ bearerAuth: [] }]
parameters:
- { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
@@ -298,10 +331,27 @@ paths:
code: { type: string, enum: ['200'] }
data: { $ref: '#/components/schemas/ContainerPlanProgressDetail' }
'404': { description: 稳定柜 ID 不存在 }
/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse:
put:
summary: 修改整柜海外接柜仓
description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须为国内 Warehouse Service 有效 OVERSEAS 仓。使用柜级版本和 Idempotency-Key,事务内原子同步柜内全部尾程订单及已有入库计划;海外PDA一旦建立接柜会话即永久锁定。
security: [{ bearerAuth: [] }]
parameters:
- { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 255 } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ChangeReceivingWarehouseRequest' }
responses:
'200': { description: 更新后的柜级摘要, content: { application/json: { schema: { type: object, properties: { code: { type: string, enum: ['200'] }, data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } } } } }
'400': { description: 请求无效或目标不是有效 OVERSEAS 仓 }
'409': { description: 版本冲突、幂等键冲突或PDA接柜会话已建立 }
/api/platform/v1/last-leg/orders/{id}/destination:
put:
summary: 将单票尾程订单转入 FBA 目的仓
description: 权限 platform:overseas-warehouse:order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。
description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
@@ -318,7 +368,7 @@ paths:
/api/platform/v1/last-leg/orders/destination:
put:
summary: 批量将尾程订单转入 FBA 目的仓
description: 权限 platform:overseas-warehouse:order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。
description: 权限 platform:overseas-warehouse:last-mile-order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。
security: [{ bearerAuth: [] }]
parameters:
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
@@ -331,26 +381,59 @@ paths:
'200': { description: 统一 ApiResponse 包装的更新订单数 }
'400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 }
'503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 }
/api/platform/v1/last-leg/inbound-plans/convert:
post:
summary: 平台生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。
/api/platform/v1/last-leg/inbound-plans:
get:
summary: 平台分页查询海外入库订单
description: 权限 platform:overseas-warehouse:inbound-order:list;列表中的 overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。
security: [{ bearerAuth: [] }]
parameters:
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: planNo, in: query, schema: { type: string } }
- { name: inboundCode, in: query, schema: { type: string } }
- { name: lxCode, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: overseasWarehouseId, in: query, schema: { type: string } }
- { name: destinationWarehouseCode, in: query, schema: { type: string } }
- { name: warehouse, in: query, schema: { type: string } }
- { name: fbaWarehouseCode, in: query, schema: { type: string } }
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } }
- { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 平台入库订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanPageResponse' } } } } }
/api/platform/v1/last-leg/inbound-plans/{planNo}:
get:
summary: 平台查询海外入库订单详情
description: 权限 platform:overseas-warehouse:inbound-order:list;详情中的 overseasWarehouseCode/overseasWarehouseName 只取当前计划来源尾程订单的预分配接柜仓事实。
security: [{ bearerAuth: [] }]
parameters:
- { name: planNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
responses:
'200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }
'404': { description: 入库订单不存在或不属于指定客户 }
/api/platform/v1/last-leg/inbound-plans/manual:
post:
summary: 平台按单票手工生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
security: [{ bearerAuth: [] }]
parameters:
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [customerId, orderId]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } }
schema: { $ref: '#/components/schemas/ManualInboundPlanRequest' }
responses:
'200': { description: 统一 ApiResponse 包装的入库计划详情 }
'400': { description: 客户、入仓码、仓库主数据或请求字段无效 }
'409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 }
/api/platform/v1/last-leg/inbound-plans/convert-container:
post:
summary: 按整柜批量生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有计划的真实 containerNo 快照一致。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等;任何柜号漂移均冲突且不部分写入。
summary: 按整柜批量生成剩余入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有有效计划的真实 containerNo 与接柜仓快照一致。已有有效计划保持不变,只为剩余尾程订单/入仓码分别生成独立计划;响应按柜内订单稳定顺序返回全部已有及新增计划。整批原子且幂等,剩余为0时直接返回已有结果;任何快照漂移均冲突且不部分写入。
security: [{ bearerAuth: [] }]
requestBody:
required: true
@@ -508,13 +591,25 @@ paths:
parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OneClickOutboundCreate' } } } }
responses: { '200': { description: 一件代发出库 PDA 任务 }, '409': { description: 库存或幂等冲突 } }
/api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates:
get:
summary: 查询一件代发出库候选库存
description: 权限 platform:overseas-warehouse:outbound:create;按客户及可选海外仓一次查询服务端权威候选,仅返回 ACTIVE 一件代发档案、OCCUPIED 正可用库存、AVAILABLE 箱唛与 SKU 余额共同覆盖且未被排车、转仓或待执行出库占用的维度。创建命令会再次校验,不信任前端候选快照。
security: [{ bearerAuth: [] }]
parameters:
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: warehouseId, in: query, required: false, schema: { type: string } }
responses:
'200':
description: 最多 500 条一件代发出库候选;单次 SQL 返回,不逐行补查
content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/OneClickOutboundCandidate' } } } }
/api/platform/v1/last-leg/outbound-orders/{outboundNo}:
get:
summary: 查询出库单详情
description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 出库单及关联派送单详情 }, '404': { description: 出库单不存在 } }
responses: { '200': { description: 出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码 }, '404': { description: 出库单不存在 } }
/api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:
post:
summary: 取消未执行出库单
@@ -526,13 +621,13 @@ paths:
/api/platform/v1/last-leg/delivery-orders:
get:
summary: 分页查询平台派送单
description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。
description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。每行返回服务端计算的 allowedActions,页面不得自行推导状态门禁。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 派送单分页 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}:
get:
summary: 查询平台派送单详情
description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读。
description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 派送单详情 }, '404': { description: 派送单不存在 } }
@@ -548,6 +643,49 @@ paths:
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
responses: { '200': { description: 已创建异常订单 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}:
get:
summary: 查询异常订单详情与冻结退回明细
description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 异常订单详情 }, '404': { description: 异常订单不存在 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:
post:
summary: 开始处理异常订单
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→PROCESSING,同版本与同幂等请求可重放。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionStartProcessing' } } } }
responses: { '200': { description: 异常处理命令结果 }, '409': { description: 版本、状态或幂等冲突 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/evidence:
post:
summary: 上传异常处理证据
description: 权限 platform:overseas-warehouse:exception-order:process;文件按当前客户和异常订单绑定为 OVERSEAS_EXCEPTION_EVIDENCE。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody:
required: true
content:
multipart/form-data:
schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } }
responses: { '200': { description: 异常处理证据元数据 }, '400': { description: 文件或归属无效 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:
post:
summary: 完成异常处理并原子恢复退回库存
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PROCESSING→COMPLETED。退回海外仓必须提交证据和实收目标,库存 RETURN_STOCK_IN 成功后才完成异常单和派送退回。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } }
responses: { '200': { description: 异常处理与库存恢复结果 }, '409': { description: 版本、状态、归属、数量或幂等冲突 } }
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:
post:
summary: 取消未处理异常订单
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionCancel' } } } }
responses: { '200': { description: 异常取消结果 }, '409': { description: 版本、状态或幂等冲突 } }
/api/platform/v1/last-leg/exception-orders/resolve:
get:
summary: 解析异常订单关联关系
@@ -557,33 +695,34 @@ paths:
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/return-inbound-options:
get:
summary: 查询派送退回候选入仓单
description: 权限 platform:overseas-warehouse:exception-order:list;属于异常订单列表只读辅助接口。
description: 权限 platform:overseas-warehouse:exception-order:list;返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 退回候选入仓单 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:
post:
summary: 将派送单标记为退回入仓
description: 权限 platform:overseas-warehouse:delivery:return。
description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端冻结源出库明细与批准退回上限。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } }
responses: { '200': { description: 已创建退回异常单 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete:
post:
summary: 标记派送完成并生成回访记录
description: 权限 platform:overseas-warehouse:delivery:status。
description: 权限 platform:overseas-warehouse:delivery:status。仅 WAIT_DELIVERY/DELIVERING 可完成;DELIVERED 同状态重放成功,其他终态或倒退返回 409。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 派送状态与回访记录 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } }
responses: { '200': { description: 派送状态与回访记录 }, '409': { description: 版本或状态冲突 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:
put:
summary: 更新派送状态
description: 权限 platform:overseas-warehouse:delivery:status。
description: 权限 platform:overseas-warehouse:delivery:status。只允许 WAIT_DELIVERY→DELIVERING/DELIVERED/REJECTED/CANCELLED、DELIVERING→DELIVERED/REJECTED;同状态幂等,终态不可倒退。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
responses: { '200': { description: 更新后的派送状态 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } }
responses: { '200': { description: 更新后的派送状态 }, '409': { description: 非法状态迁移 } }
/api/platform/v1/last-leg/customer-visits:
get:
summary: 分页查询客户回访
@@ -811,7 +950,7 @@ paths:
/api/merchant/v1/last-leg/inbound-plans:
get:
summary: 客户分页查询自己的海外入库订单
description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId;overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -829,7 +968,7 @@ paths:
/api/merchant/v1/last-leg/inbound-plans/{planNo}:
get:
summary: 客户查询自己的海外入库订单详情
description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId。
description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId;overseasWarehouseCode/overseasWarehouseName 只取该计划来源尾程订单的预分配接柜仓事实。
security: [{ bearerAuth: [] }]
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }, '404': { description: 当前客户下入库订单不存在 } }
@@ -1186,6 +1325,39 @@ components:
height: { type: number, exclusiveMinimum: 0, description: '箱高,CM' }
weight: { type: number, exclusiveMinimum: 0, description: '箱重,KG' }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
InventoryBalance:
type: object
required: [id, customerId, totalPackages, availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, receivingPackages, procurementPackages]
properties:
id: { type: string, pattern: '^[1-9][0-9]*$', description: 库存余额长整型ID,按字符串传输 }
customerId: { type: string }
customerName: { type: string, nullable: true }
overseasWarehouseId: { type: string, nullable: true }
overseasWarehouseCode: { type: string, nullable: true }
overseasWarehouseName: { type: string, nullable: true }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
inboundOrderId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 }
inboundOrderNo: { type: string, nullable: true }
inboundCode: { type: string, nullable: true }
skuCode: { type: string, nullable: true }
productNameZh: { type: string, nullable: true }
productNameEn: { type: string, nullable: true }
totalPackages: { type: integer, format: int64, minimum: 0 }
availablePackages: { type: integer, format: int64, minimum: 0 }
holdPackages: { type: integer, format: int64, minimum: 0 }
allocatedPackages: { type: integer, format: int64, minimum: 0 }
pickedPackages: { type: integer, format: int64, minimum: 0 }
inTransitPackages: { type: integer, format: int64, minimum: 0 }
receivingPackages: { type: integer, format: int64, minimum: 0 }
procurementPackages: { type: integer, format: int64, minimum: 0 }
updatedAt: { type: string, format: date-time, nullable: true }
InventoryBalanceApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
properties: { data: { $ref: '#/components/schemas/InventoryBalance' } }
InboundCodeRelationship:
type: object
required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory]
@@ -1259,10 +1431,16 @@ components:
items: { type: string, pattern: '^[1-9][0-9]*$' }
ContainerPlanProgressSummary:
type: object
required: [sourceContainerBookingId, containerNo, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
properties:
sourceContainerBookingId: { type: string }
containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 }
overseasWarehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 平台预分配的海外接柜仓ID,不是FBA目的仓 }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码 }
overseasWarehouseName: { type: string, description: 海外接柜仓业务名称 }
assignmentVersion: { type: integer, minimum: 0, description: 柜级接柜仓乐观锁版本 }
canChangeReceivingWarehouse: { type: boolean }
receivingWarehouseLockReason: { type: string, nullable: true }
totalOrderCount: { type: integer, format: int64 }
inboundCodeCount: { type: integer, format: int64 }
missingInboundCodeCount: { type: integer, format: int64 }
@@ -1272,6 +1450,13 @@ components:
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at }
allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true }
blockingReason: { type: string, nullable: true }
ChangeReceivingWarehouseRequest:
type: object
required: [overseasWarehouseId, expectedVersion]
properties:
overseasWarehouseId: { type: integer, format: int64, minimum: 1 }
expectedVersion: { type: integer, minimum: 0 }
remark: { type: string, maxLength: 500, nullable: true }
ContainerPlanProgressDetail:
type: object
required: [summary, orders]
@@ -1495,10 +1680,12 @@ components:
warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 }
ReceivingCandidate:
type: object
required: [sourceContainerBookingId, containerNo, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
required: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
properties:
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
containerNo: { type: string }
overseasWarehouseCode: { type: string }
overseasWarehouseName: { type: string }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
totalOrderCount: { type: integer, minimum: 1 }
missingInboundCodeCount: { type: integer, minimum: 0 }
@@ -1517,10 +1704,11 @@ components:
currentAccount: { type: boolean }
ReceivingSession:
type: object
required: [containerNo, overseasWarehouseId, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
properties:
containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: PDA Token绑定并在认领柜号时冻结的共享实际海外收货仓ID }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
operatorName: { type: string, nullable: true }
@@ -1637,7 +1825,7 @@ components:
orderNo: { type: string }
orderRefs: { type: string }
inboundCode: { type: string }
palletNo: { type: string }
palletNo: { type: string, description: 未入库时为计划托盘码;绑定实物托盘后为 Warehouse 权威实物托盘码 }
destinationWarehouseId: { type: string }
destinationWarehouseCode: { type: string }
destinationWarehouseName: { type: string }
@@ -1729,6 +1917,76 @@ components:
sellerName: { type: string, nullable: true, maxLength: 128 }
remark: { type: string, nullable: true, maxLength: 1000 }
fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } }
DeliveryStatusChange:
type: object
required: [status, expectedVersion]
properties:
status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, DELIVERED, REJECTED, CANCELLED] }
expectedVersion: { type: integer, format: int64, minimum: 0 }
remark: { type: string, nullable: true, maxLength: 500 }
DeliveryAllowedAction:
type: string
enum: [START_DELIVERY, COMPLETE_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN]
description: START/COMPLETE/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口。
DeliveryMarkReturn:
type: object
required: [inboundOrderId, reason, returnDirection, items, idempotencyKey]
properties:
inboundOrderId: { type: string, pattern: '^[1-9][0-9]*$' }
reason: { type: string, minLength: 1, maxLength: 1000 }
returnDirection: { type: string, enum: [OVERSEAS_WAREHOUSE, CUSTOMER, OTHER] }
returnLogisticsNo: { type: string, nullable: true, maxLength: 128 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
items:
type: array
minItems: 1
maxItems: 200
items:
type: object
required: [outboundPalletItemId, inventoryPositionId, packageCount]
properties:
outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' }
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
packageCount: { type: integer, format: int64, minimum: 1 }
ExceptionStartProcessing:
type: object
required: [expectedVersion, idempotencyKey]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
handlingAction: { type: string, nullable: true, maxLength: 1000 }
ExceptionCancel:
type: object
required: [expectedVersion, idempotencyKey, reason]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
reason: { type: string, minLength: 1, maxLength: 1000 }
ExceptionComplete:
type: object
required: [expectedVersion, idempotencyKey, handlingResult, evidenceFileIds]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
handlingResult: { type: string, minLength: 1, maxLength: 2000 }
evidenceFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string } }
receipts:
type: array
maxItems: 200
items:
type: object
required: [outboundPalletItemId, inventoryPositionId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode]
properties:
outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' }
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
receivedPackages: { type: integer, format: int64, minimum: 1 }
targetPalletId: { type: string, pattern: '^[1-9][0-9]*$' }
targetPalletNo: { type: string, minLength: 1, maxLength: 128 }
targetLocationId: { type: string, pattern: '^[1-9][0-9]*$' }
targetLocationCode: { type: string, minLength: 1, maxLength: 128 }
ExceptionAllowedAction:
type: string
enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION]
DeliveryFeeInput:
type: object
required: [feeType, currency, amount]
@@ -1755,6 +2013,18 @@ components:
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
packages: { type: integer, format: int64, minimum: 1 }
sku: { type: string, maxLength: 128 }
OneClickOutboundCandidate:
type: object
required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages]
properties:
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$', description: 创建命令提交键,不作为用户展示编号 }
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string, nullable: true }
palletNo: { type: string }
inboundCode: { type: string, nullable: true }
skuCode: { type: string }
productName: { type: string, nullable: true }
availablePackages: { type: integer, format: int64, minimum: 1, description: 库存位置、SKU余额和AVAILABLE箱唛三者的可用最小值 }
OutboundCancel:
type: object
required: [reason]
@@ -1909,6 +2179,58 @@ components:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } }
LastlegOrderHoldRequest:
type: object
required: [held, chargeable, reason, expectedVersion]
properties:
held: { type: boolean }
chargeable: { type: boolean, description: 仅 held=true 时生效;解除暂扣后固定为 false }
reason: { type: string, minLength: 1, maxLength: 500 }
expectedVersion: { type: integer, minimum: 0 }
LastlegOrderHoldResponse:
type: object
required: [orderId, held, chargeable, version]
properties:
orderId: { type: integer, format: int64 }
held: { type: boolean }
chargeable: { type: boolean }
reason: { type: string, nullable: true }
version: { type: integer, minimum: 0 }
LastlegOrderManualNodeCode:
type: string
enum: [DOMESTIC_CUSTOMS_COMPLETED, ESTIMATED_DEPARTURE, ACTUAL_DEPARTURE, ESTIMATED_ARRIVAL, OVERSEAS_CUSTOMS_COMPLETED, ACTUAL_ARRIVAL_WAITING_UNLOAD, PICKUP_APPOINTED, UNLOADED]
LastlegOrderManualNode:
type: object
required: [nodeCode]
properties:
nodeCode: { $ref: '#/components/schemas/LastlegOrderManualNodeCode' }
nodeAt: { type: string, format: date-time, nullable: true }
LastlegOrderManualNodesRequest:
type: object
required: [expectedVersion, nodes]
properties:
expectedVersion: { type: integer, minimum: 0 }
nodes:
type: array
minItems: 8
maxItems: 8
description: 必须包含8个枚举节点且各出现一次,至少一个 nodeAt 非空。
items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
ManualInboundPlanRequest:
type: object
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。
required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
orderId: { type: integer, format: int64, minimum: 1 }
expectedOrderVersion: { type: integer, minimum: 0 }
plannedArrivalDate: { type: string, format: date }
palletCount: { type: integer, minimum: 1, maximum: 999 }
palletType: { type: string, minLength: 1, maxLength: 64 }
assigned: { type: boolean }
mergeRequired: { type: boolean }
urgent: { type: boolean }
remark: { type: string, nullable: true, maxLength: 1000 }
PageMetadata:
type: object
required: [page, size, total, pages]
@@ -2090,7 +2412,7 @@ components:
properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } }
InboundPlanSummary:
type: object
required: [lineId, planId, planNo, customerId, orderId, inboundCode, packageCount, pickedPackageCount, palletCount, status, createdAt, version]
required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, palletCount, status, createdAt, version]
properties:
lineId: { type: string, pattern: '^[0-9]+$' }
planId: { type: string, pattern: '^[0-9]+$' }
@@ -2099,6 +2421,8 @@ components:
orderId: { type: string, pattern: '^[0-9]+$' }
inboundCode: { type: string }
orderType: { type: string, nullable: true }
overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }
overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }
warehouseId: { type: string, nullable: true }
warehouseCode: { type: string, nullable: true }
warehouseName: { type: string, nullable: true }
@@ -2118,19 +2442,28 @@ components:
version: { type: integer }
InboundPlanDetail:
type: object
required: [id, planNo, customerId, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
properties:
id: { type: string, pattern: '^[0-9]+$' }
planNo: { type: string }
customerId: { type: string }
sourceOrderId: { type: string, pattern: '^[0-9]+$', nullable: true }
containerNo: { type: string, nullable: true }
overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }
overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }
status: { type: string }
lineCount: { type: integer }
palletCount: { type: integer }
totalVolumeCbm: { type: number }
expectedPackageCount: { type: integer }
pickedPackageCount: { type: integer }
plannedArrivalDate: { type: string, format: date, nullable: true }
palletType: { type: string, nullable: true }
assigned: { type: boolean }
mergeRequired: { type: boolean }
urgent: { type: boolean }
creationMode: { type: string, enum: [AUTO, MANUAL] }
remark: { type: string, nullable: true }
lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } }
pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } }
allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } }
@@ -2153,6 +2486,7 @@ components:
properties:
id: { type: string, pattern: '^[0-9]+$' }
palletNo: { type: string }
palletType: { type: string, nullable: true }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
@@ -46,6 +46,14 @@ paths:
description: 一件代发复用统一出库任务模型;不接收customerId或warehouseId,派送不进入PDA。
parameters: [{ name: status, in: query, required: false, schema: { type: string } }]
responses: { '200': { description: 出库任务列表 } }
/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:
get:
summary: 解析出库作业扫码业务号
description: 接受一件代发任务号、出库单号或排车单号;按Token绑定仓返回 OUTBOUND 的 taskNo 或 DISPATCH 的 dispatchNo,PDA据此进入正确作业页。
parameters: [{ name: businessNo, in: path, required: true, schema: { type: string } }]
responses:
'200': { description: 正确作业类型及稳定业务编号 }
'404': { description: 当前绑定仓无此出库或排车任务 }
/api/pda/v1/last-leg/outbound-tasks/{taskNo}:
get:
summary: 查询一件代发出库任务详情
@@ -581,7 +589,7 @@ components:
required: [outboundPalletItemId, palletNo, skuCode, inboundCode, packageCount, pickedPackageCount, status, positions]
properties:
outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 }
palletNo: { type: string }
palletNo: { type: string, description: Warehouse 权威实物托盘码 }
skuCode: { type: string }
inboundCode: { type: string }
packageCount: { type: integer }
@@ -594,7 +602,7 @@ components:
required: [inventoryPositionId, palletNo, cartonMark, skuCode, inboundCode, availablePackages, pickedPackages]
properties:
inventoryPositionId: { type: string, description: pick请求提交键,不作为用户展示编号 }
palletNo: { type: string }
palletNo: { type: string, description: Warehouse 权威实物托盘码 }
cartonMark: { type: string, nullable: true }
skuCode: { type: string }
inboundCode: { type: string }
@@ -0,0 +1,16 @@
package com.baiyue.lastleg;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeanUtils;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class QueryBindingContractTest {
@Test
void mvcQueryRecordsExposeOneResolvableConstructor() {
assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(DeliveryRequests.Query.class));
assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(InboundPlanRequests.Query.class));
}
}
@@ -9,11 +9,14 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LastlegOrderCommandService;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
@@ -32,6 +35,7 @@ import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
// Controller contract tests intentionally instantiate the controller without a Spring context here.
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@@ -59,6 +63,23 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void holdCommandPassesVersionedIdempotentRequestToSingleOrderService() {
var request = new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 2);
controller.hold(user(0L, "PLATFORM"), "100", 11L, "hold-11", request);
verify(commandService).hold("100", 11L, "hold-11", request, "1", "tester");
}
@Test
void manualNodeQueryUsesOrderScopeRatherThanContainerScope() {
controller.manualNodes(user(0L, "PLATFORM"), "100", 11L);
verify(commandService).manualNodes("100", 11L);
verify(service, never()).containerProgressDetail(org.mockito.ArgumentMatchers.anyString());
}
@Test
void platformOperatorCanQueryCrossCustomerContainerSummaries() {
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null);
@@ -68,6 +89,24 @@ class LastlegOrderControllerTest {
verify(service).containerProgressPage(summaryQuery);
}
@Test
void containerEndpointsUseCanonicalLastMileOrderAuthorities() throws Exception {
var list = LastlegOrderController.class.getMethod("containerSummaries",
AuthenticatedUser.class, LastlegOrderRequests.ContainerProgressQuery.class);
var detail = LastlegOrderController.class.getMethod("containerSummaryDetail",
AuthenticatedUser.class, String.class);
var change = LastlegOrderController.class.getMethod("changeReceivingWarehouse",
AuthenticatedUser.class, String.class, String.class,
LastlegOrderRequests.ChangeReceivingWarehouse.class);
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')",
list.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')",
detail.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:edit')",
change.getAnnotation(PreAuthorize.class).value());
}
@Test
void containerSummariesRejectMerchantIdentity() {
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null);
@@ -130,6 +169,7 @@ class LastlegOrderControllerTest {
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class));
ReflectionTestUtils.setField(controller, "orderService", service);
ReflectionTestUtils.setField(controller, "commandService", commandService);
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
return controller;
}
@@ -0,0 +1,118 @@
package com.baiyue.lastleg.customervisit.service;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests;
import com.baiyue.lastleg.customervisit.entity.CustomerVisit;
import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomerVisitServiceTest {
@Mock private CustomerVisitMapper visits;
@Mock private DeliveryOrderMapper deliveryOrders;
@Mock private DeliveryOperationMapper deliveryOperations;
@Mock private LastlegOrderMapper lastMileOrders;
private CustomerVisitService service;
@BeforeEach
void setUp() {
service = new CustomerVisitService();
ReflectionTestUtils.setField(service, "visits", visits);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
}
@Test
void rejectsRollbackFromDeliveredToWaitDeliveryWithoutWritingHistory() {
DeliveryOrder delivery = delivery("DELIVERED");
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", 0L, "错误倒退")))
.isInstanceOf(ResponseStatusException.class)
.satisfies(error -> assertThat(((ResponseStatusException) error).getStatusCode().value()).isEqualTo(409))
.hasMessageContaining("DELIVERED").hasMessageContaining("WAIT_DELIVERY");
verify(deliveryOrders, never()).updateById(delivery);
verifyNoInteractions(deliveryOperations);
}
@Test
void replaysDeliveredToDeliveredWithoutChangingDeliveryOrAddingHistory() {
DeliveryOrder delivery = delivery("DELIVERED");
CustomerVisit visit = new CustomerVisit();
visit.setId(33L);
visit.setStatus("PENDING");
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null));
assertThat(result.replayed()).isTrue();
assertThat(result.deliveryStatus()).isEqualTo("DELIVERED");
assertThat(result.visitId()).isEqualTo(33L);
verify(deliveryOrders, never()).updateById(delivery);
verifyNoInteractions(deliveryOperations);
}
@Test
void rejectsForwardTransitionWithStaleVersion() {
DeliveryOrder delivery = delivery("WAIT_DELIVERY");
delivery.setVersion(3L);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 2L, null)))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verify(deliveryOrders, never()).updateById(delivery);
verifyNoInteractions(deliveryOperations);
}
@Test
void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() {
DeliveryOrder delivery = delivery("DELIVERING");
delivery.setVersion(1L);
LastlegOrder tail = new LastlegOrder();
tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED");
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail));
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
assertThat(result.replayed()).isFalse();
assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED");
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
}
private DeliveryOrder delivery(String status) {
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(11L);
delivery.setCustomerId("1001");
delivery.setDeliveryNo("DL-1");
delivery.setStatus(status);
return delivery;
}
}
@@ -66,6 +66,6 @@ class DeliveryLongIdJsonContractTest {
DELIVERY_ID,
null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null);
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of());
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DropshipOutboundCandidateMapperContractTest {
@Test
void candidateQueryIsSingleStatementAndFailsClosedOnEveryEligibilityFact() throws Exception {
String sql = select("listOutboundCandidates", String.class, String.class);
for (String token : new String[] {"p.customer_id=#{customerId}", "p.status='ACTIVE'",
"o.order_type='ONE_CLICK_DROPSHIP'", "COALESCE(o.detained,0)=0", "pos.status='OCCUPIED'",
"pos.available_packages>0", "s.package_count>0", "c.status='AVAILABLE'",
"ll_dispatch_pallet_lock", "ll_warehouse_transfer_inventory_allocation",
"ll_outbound_pallet_item", "pos.overseas_warehouse_id=#{warehouseId}", "LIMIT 500"}) {
assertTrue(sql.contains(token), token);
}
assertTrue(sql.contains("LEAST(pos.available_packages,s.package_count"));
}
@Test
void creationLockUsesTheSameEligibilityContractAndLocksOneSkuDimension() throws Exception {
String sql = select("lockOutboundCandidate", String.class, Long.class, String.class);
assertTrue(sql.contains("AND pos.id=#{positionId}"));
assertTrue(sql.contains("AND s.sku_code=#{skuCode}"));
assertTrue(sql.contains("LIMIT 1 FOR UPDATE"));
assertTrue(sql.contains("ll_dispatch_pallet_lock"));
assertTrue(sql.contains("ll_outbound_pallet_item"));
}
private String select(String method, Class<?>... parameterTypes) throws Exception {
Select annotation = DropshipOrderProfileMapper.class.getMethod(method, parameterTypes)
.getAnnotation(Select.class);
assertNotNull(annotation);
return String.join("", annotation.value());
}
}
@@ -11,6 +11,12 @@ import com.baiyue.lastleg.security.OverseasPermissions;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
@@ -25,8 +31,31 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PlatformDispatchOutboundContractTest {
@Test
void outboundDetailAcceptsTheDocumentUrlWithoutPagingParameters() throws Exception {
DeliveryTransferController controller = new DeliveryTransferController();
ReflectionTestUtils.setField(controller, "service",
mock(com.baiyue.lastleg.delivery.service.DeliveryTransferService.class));
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver())
.build();
AuthenticatedUser platform = new AuthenticatedUser(1000001L, 0L, 0L, 1L, "platform_admin",
"PLATFORM", "WEB", Set.of(OverseasPermissions.OUTBOUND_LIST));
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(platform, null, Set.of()));
try {
mockMvc.perform(get("/api/platform/v1/last-leg/outbound-orders/OB-WH-20260907-030529"))
.andExpect(status().isOk());
} finally {
SecurityContextHolder.clearContext();
}
}
@Test
void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception {
Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class,
@@ -35,7 +64,7 @@ class PlatformDispatchOutboundContractTest {
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
String.class, DeliveryRequests.Transfer.class);
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
AuthenticatedUser.class, String.class, DeliveryRequests.PalletQuery.class);
AuthenticatedUser.class, String.class);
Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound",
AuthenticatedUser.class, String.class, DeliveryRequests.Cancel.class);
@@ -43,6 +72,7 @@ class PlatformDispatchOutboundContractTest {
assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL);
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
assertEquals(2, outboundDetail.getParameterCount());
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) {
assertTrue(Arrays.stream(method.getParameters())
@@ -60,15 +90,20 @@ class PlatformDispatchOutboundContractTest {
void oneClickRequiresCustomerWhileOutboundListKeepsOptionalFilter() throws Exception {
Method create = DeliveryTransferController.class.getMethod("createOneClick", AuthenticatedUser.class,
String.class, DeliveryRequests.OneClickOutbound.class);
Method candidates = DeliveryTransferController.class.getMethod("oneClickCandidates", AuthenticatedUser.class,
String.class, String.class);
Method page = DeliveryTransferController.class.getMethod("outboundPage", AuthenticatedUser.class,
String.class, DeliveryRequests.Query.class);
assertPermission(create, OverseasPermissions.OUTBOUND_CREATE);
assertPermission(candidates, OverseasPermissions.OUTBOUND_CREATE);
assertPermission(page, OverseasPermissions.OUTBOUND_LIST);
RequestParam createCustomer = create.getParameters()[1].getAnnotation(RequestParam.class);
RequestParam pageCustomer = page.getParameters()[1].getAnnotation(RequestParam.class);
assertNotNull(createCustomer);
assertTrue(createCustomer.required());
assertTrue(candidates.getParameters()[1].getAnnotation(RequestParam.class).required());
assertFalse(candidates.getParameters()[2].getAnnotation(RequestParam.class).required());
assertNotNull(pageCustomer);
assertFalse(pageCustomer.required());
@@ -91,8 +126,7 @@ class PlatformDispatchOutboundContractTest {
() -> dispatchController.cancel(merchant, "DSP-101"));
ResponseStatusException outboundRejected = org.junit.jupiter.api.Assertions.assertThrows(
ResponseStatusException.class,
() -> deliveryController.outboundDetail(merchant, "OUT-101",
new DeliveryRequests.PalletQuery(1, 20, null)));
() -> deliveryController.outboundDetail(merchant, "OUT-101"));
assertEquals(403, dispatchRejected.getStatusCode().value());
assertEquals(403, outboundRejected.getStatusCode().value());
@@ -137,6 +171,10 @@ class PlatformDispatchOutboundContractTest {
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
assertFalse(action.contains("name: customerId"));
}
assertTrue(outboundDetail.contains("pallets[].palletNo"));
assertTrue(yaml.contains("/api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates:"));
assertTrue(yaml.contains("OneClickOutboundCandidate"));
assertTrue(yaml.contains("单次 SQL 返回,不逐行补查"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.delivery.model;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DeliveryStatusPolicyTest {
@Test
void exposesOnlyServerAuthorizedActionsForEachState() {
assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY"))
.containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING"))
.containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty();
assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty();
}
@Test
void terminalStatesCannotReenterTheDeliveryFlow() {
assertThat(DeliveryStatusPolicy.canTransition("DELIVERED", "WAIT_DELIVERY")).isFalse();
assertThat(DeliveryStatusPolicy.canTransition("DELIVERED", "DELIVERING")).isFalse();
assertThat(DeliveryStatusPolicy.canTransition("REJECTED", "DELIVERING")).isFalse();
assertThat(DeliveryStatusPolicy.canTransition("CANCELLED", "DELIVERED")).isFalse();
}
}
@@ -19,6 +19,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -34,6 +35,8 @@ import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -53,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -68,6 +72,7 @@ class DeliveryTransferServiceTest {
@Mock DeliveryOperationMapper deliveryOperations;
@Mock InboundPlanPalletAllocationMapper planAllocations;
@Mock InboundPlanLineMapper planLines;
@Mock InboundPlanCartonMapper inboundCartons;
@Mock LastlegOrderMapper tailOrders;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderDetailMapper tailOrderDetails;
@@ -77,6 +82,7 @@ class DeliveryTransferServiceTest {
@Mock InventoryBalanceMapper inventoryBalances;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock DropshipCartonMapper dropshipCartons;
@Mock DropshipSkuInventoryMapper dropshipSkuInventory;
private DeliveryTransferService service;
@@ -92,6 +98,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
ReflectionTestUtils.setField(service, "inboundCartons", inboundCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
@@ -102,6 +109,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory);
}
@Test
@@ -140,6 +148,40 @@ class DeliveryTransferServiceTest {
assertTrue(!result.replayed());
}
@Test
void transferBackfillsReceivedPlanPalletsBeforeCheckingDispatchItems() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
when(dispatchItems.bindReceivedPallets("TENANT-A", 101L)).thenReturn(1);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-after-receiving", null));
var ordered = inOrder(dispatchItems);
ordered.verify(dispatchItems).bindReceivedPallets("TENANT-A", 101L);
ordered.verify(dispatchItems).findByDispatchOrder("TENANT-A", 101L);
}
@Test
void transferCollectsLxCodesWhenAnInboundCodeBelongsToMultipleTailOrders() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
LastlegOrder primary = new LastlegOrder();
primary.setCustomerId("TENANT-A"); primary.setInboundCode("IN-A"); primary.setLxCode("LX-1");
LastlegOrder derived = new LastlegOrder();
derived.setCustomerId("TENANT-A"); derived.setInboundCode("IN-A"); derived.setLxCode(null);
when(tailOrders.selectList(any())).thenReturn(List.of(primary, derived), List.of());
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-duplicate-inbound", null));
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
verify(outboundOrders).insert(outbound.capture());
assertEquals("LX-1", outbound.getValue().getLxCodes());
}
@Test
void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
@@ -235,20 +277,26 @@ class DeliveryTransferServiceTest {
}
@Test
void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() {
void outboundDetailIncludesPalletCodesForDisplay() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
OutboundPalletItem pallet = pallet(501L, "IP-ONT8-20260907-000001-P001", "ONT8", "Ontario", "IN-A", 12);
pallet.setSkuCode("SKU-1");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet));
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
assertEquals("OUT-301", detail.outbound().outboundNo());
assertEquals("DEL-401", detail.delivery().deliveryNo());
assertTrue(java.util.Arrays.stream(DeliveryResponses.OutboundDetail.class.getRecordComponents())
.noneMatch(component -> "pallets".equals(component.getName())));
verify(outboundPallets, never()).selectPage(any(Page.class), any());
assertEquals(1, detail.pallets().size());
assertEquals("IP-ONT8-20260907-000001-P001", detail.pallets().get(0).palletNo());
assertEquals("IN-A", detail.pallets().get(0).inboundCode());
assertEquals("ONT8", detail.pallets().get(0).destinationWarehouseCode());
assertEquals("SKU-1", detail.pallets().get(0).skuCode());
assertEquals(12, detail.pallets().get(0).packageCount());
verify(outboundPallets).findByOutbound("TENANT-A", 301L);
}
@Test
@@ -258,8 +306,7 @@ class DeliveryTransferServiceTest {
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
assertEquals("OUT-301", detail.outbound().outboundNo());
verify(deliveryOrders).lockByOutbound("TENANT-A", 301L);
@@ -358,12 +405,8 @@ class DeliveryTransferServiceTest {
@Test
void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() {
InventoryPosition position = new InventoryPosition();
position.setId(901L); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1");
position.setPalletId(501L); position.setPalletNo("PLT-501"); position.setAvailablePackages(1L);
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
when(inventoryPositions.selectById(901L)).thenReturn(position);
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L);
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(candidate);
when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "MIXED", 1L)).thenReturn(List.of());
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick(
@@ -371,9 +414,43 @@ class DeliveryTransferServiceTest {
new DeliveryRequests.OneClickItem(901L, 1L, "MIXED")), null, "one-click-mixed")));
assertEquals(409, exception.getStatusCode().value());
assertTrue(exception.getReason().contains("一件代发可用箱数不足"));
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
}
@Test
void oneClickCandidatesUseOneAuthoritativeQueryAndExposeStableBusinessFields() {
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "DS-A002", "SKU-1", 3L);
candidate.setOverseasWarehouseName("Los Angeles"); candidate.setInboundCode("IN-1");
candidate.setProductName("Product 1");
when(dropshipProfiles.listOutboundCandidates("TENANT-A", "WH-1")).thenReturn(List.of(candidate));
List<DeliveryResponses.OneClickCandidate> result = service.oneClickCandidates("TENANT-A", " WH-1 ");
assertEquals(1, result.size());
assertEquals(901L, result.get(0).inventoryPositionId());
assertEquals("DS-A002", result.get(0).palletNo());
assertEquals("SKU-1", result.get(0).skuCode());
assertEquals(3L, result.get(0).availablePackages());
verify(dropshipProfiles).listOutboundCandidates("TENANT-A", "WH-1");
verify(inventoryPositions, never()).selectById(any());
}
@Test
void oneClickCreationRejectsOrdinaryInventoryBeforeAnyWrite() {
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick(
"TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
new DeliveryRequests.OneClickItem(901L, 1L, "MIXED")), null, "ordinary-stock")));
assertEquals(409, exception.getStatusCode().value());
assertEquals("所选库存不是可用的一件代发库存,或已被其他作业占用", exception.getReason());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
verify(outboundPallets, never()).insert(any(OutboundPalletItem.class));
verify(pdaTasks, never()).insert(any(OutboundPdaTask.class));
}
@Test
void outboundPdaListAndDetailUseOnlyBoundWarehouse() throws Exception {
assertNotNull(DeliveryTransferService.class.getMethod("pdaPick", String.class, String.class,
@@ -393,6 +470,53 @@ class DeliveryTransferServiceTest {
verify(pdaTasks).listByWarehouse("WH-1", null);
}
@Test
void outboundNumberResolvesDispatchWorkflowInBoundWarehouse() {
OutboundOrder outbound = outbound(301L, 401L, "DSP-401", "OUT-301", "transfer-key");
outbound.setOverseasWarehouseId("WH-1");
outbound.setOutboundType("DISPATCH_DELIVERY");
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(dispatchOrders.findGlobalByDispatchNo("DSP-401")).thenReturn(dispatch);
DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301");
assertEquals("DISPATCH", result.workflow());
assertEquals("DSP-401", result.dispatchNo());
assertEquals("OUT-301", result.outboundNo());
}
@Test
void outboundNumberResolvesOneClickTaskInBoundWarehouse() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setOverseasWarehouseId("WH-1");
outbound.setOutboundType("ONE_CLICK_DROPSHIP");
OutboundPdaTask task = new OutboundPdaTask();
task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setTaskNo("PDA-701");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(pdaTasks.findByOutboundAndWarehouse("TENANT-A", 301L, "WH-1")).thenReturn(task);
DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301");
assertEquals("OUTBOUND", result.workflow());
assertEquals("PDA-701", result.taskNo());
assertEquals("OUT-301", result.outboundNo());
}
@Test
void pdaTaskResolutionDoesNotLeakAnotherWarehouse() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setOverseasWarehouseId("WH-2");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.resolvePdaTask("WH-1", "OUT-301"));
assertEquals(404, exception.getStatusCode().value());
verify(pdaTasks, never()).findByOutboundAndWarehouse(any(), any(), any());
}
@Test
void outboundPdaDetailReturnsEveryPickablePositionWithStableBusinessCodes() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
@@ -565,4 +689,12 @@ class DeliveryTransferServiceTest {
value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages);
return value;
}
private static DropshipOutboundCandidate dropshipCandidate(Long positionId, Long profileId, String warehouseId,
String palletNo, String sku, Long packages) {
DropshipOutboundCandidate value = new DropshipOutboundCandidate();
value.setInventoryPositionId(positionId); value.setProfileId(profileId); value.setWarehousePalletId(501L);
value.setOverseasWarehouseId(warehouseId); value.setPalletNo(palletNo); value.setSkuCode(sku);
value.setAvailablePackages(packages); return value;
}
}
@@ -35,6 +35,20 @@ class DispatchCandidateMapperContractTest {
assertTrue(sql.contains("pos.available_packages &gt; 0"));
}
@Test
void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception {
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
assertTrue(sql.contains("pos.overseas_warehouse_id"));
assertTrue(sql.contains("CAST(ip.overseas_warehouse_id AS CHAR)"));
assertTrue(sql.contains("ll_inventory_balance"));
}
}
private void assertConstructorColumnOrder(Method method) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
int previous = -1;
@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
@@ -16,6 +18,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,6 +36,7 @@ import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -49,6 +54,7 @@ class DispatchServiceTest {
@Mock DispatchDraftMapper drafts;
@Mock DispatchDraftContainerMapper draftContainers;
@Mock DispatchPdaTaskMapper pdaTasks;
@Mock LastlegOrderMapper tailOrders;
private DispatchService service;
@BeforeEach
@@ -61,8 +67,26 @@ class DispatchServiceTest {
ReflectionTestUtils.setField(service, "drafts", drafts);
ReflectionTestUtils.setField(service, "draftContainers", draftContainers);
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
lenient().when(tailOrders.selectList(any())).thenReturn(List.of());
}
@Test
void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
LastlegOrder held = new LastlegOrder();
held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true);
when(tailOrders.selectList(any())).thenReturn(List.of(held));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
null, null, null)));
assertEquals(409, exception.getStatusCode().value());
assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason());
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
@@ -127,6 +151,21 @@ class DispatchServiceTest {
verify(items).insert(any(DispatchItem.class));
}
@Test
void preArrangeRejectsPalletWithoutResolvedOverseasWarehouseWithPreciseReason() {
DispatchPalletCandidate unresolved = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
null, null, "LMO1", "LMO1", "RK1", "LIP1-P001", "ONT8", "ONT8", "ONT8", "ONT8",
12, new BigDecimal("1.2000"), "RECEIVED");
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(unresolved));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange("100", "7",
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
assertEquals("所选托盘中存在尚未关联海外仓的托盘", exception.getReason());
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
@@ -145,15 +184,41 @@ class DispatchServiceTest {
void preArrangeConvertsOwnedDraftLockInsteadOfInsertingAnotherLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
DispatchPalletLock lock = new DispatchPalletLock();
lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
DispatchDraft draft = new DispatchDraft();
draft.setId(31L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE");
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
when(drafts.lockActive("draft-1", "7")).thenReturn(draft);
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1"));
assertNotNull(lock.getDispatchOrderId());
assertEquals(null, lock.getDraftToken());
assertNull(lock.getDraftToken());
assertNull(lock.getDraftContainerId());
assertEquals("RELEASED", draft.getStatus());
assertNotNull(draft.getReleasedAt());
verify(locks).updateById(lock);
verify(locks, never()).insert(any(DispatchPalletLock.class));
verify(draftContainers).delete(any(LambdaQueryWrapper.class));
verify(drafts).updateById(draft);
}
@Test
void activeDraftOnlyRestoresLocksStillOwnedByThatDraft() {
DispatchDraft draft = new DispatchDraft();
draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE");
DispatchDraftContainer container = new DispatchDraftContainer();
container.setId(21L); container.setDraftToken("draft-1"); container.setCapacity(12);
DispatchPalletLock owned = new DispatchPalletLock();
owned.setPlanPalletId(10L); owned.setDraftToken("draft-1"); owned.setDraftContainerId(21L);
when(drafts.findActiveByOperator("7")).thenReturn(List.of(draft));
when(draftContainers.findByToken("draft-1")).thenReturn(List.of(container));
when(locks.findActiveByDraftContainer("draft-1", 21L)).thenReturn(List.of(owned));
var result = service.activeDrafts("7");
assertEquals(List.of("10"), result.get(0).containers().get(0).palletIds());
verify(locks).findActiveByDraftContainer("draft-1", 21L);
}
@Test
@@ -141,7 +141,8 @@ class DropshipOrderServiceTest {
private static InboundStockReceipt receipt(long palletId, String palletNo, long positionId, long packages) {
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId); receipt.setPalletNo(palletNo);
receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId);
receipt.setPalletNo("WH-" + palletId); receipt.setSourcePalletNo(palletNo);
receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages);
return receipt;
}
@@ -0,0 +1,159 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.List;
@ExtendWith(MockitoExtension.class)
class ExceptionOrderLifecycleTest {
@Mock private ExceptionOrderMapper exceptionOrders;
@Mock private ExceptionOrderCommandMapper commands;
@Mock private ExceptionReturnItemMapper returnItems;
@Mock private InventoryCommandService inventoryCommands;
@Mock private ExceptionEvidenceFileGateway evidenceFiles;
@Mock private DeliveryOrderMapper deliveryOrders;
@Mock private DeliveryOperationMapper deliveryOperations;
@Mock private LastlegOrderMapper lastMileOrders;
private ExceptionOrderService service;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
objectMapper = new ObjectMapper().findAndRegisterModules();
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "returnItems", returnItems);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "evidenceFiles", evidenceFiles);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
}
@Test
void startsPendingExceptionWithVersionGateAndPersistsIdempotentResult() {
ExceptionOrder order = order("PENDING", 0);
when(commands.findByKey("1001", "START_PROCESSING", "start-1")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
var result = service.startProcessing("1001", "9001", "EX-1",
new ExceptionOrderRequests.StartProcessing(0, "start-1", "联系客户并核对退件"));
assertThat(result.status()).isEqualTo("PROCESSING");
assertThat(result.version()).isEqualTo(1);
assertThat(result.replayed()).isFalse();
verify(exceptionOrders).updateById(order);
ArgumentCaptor<ExceptionOrderCommand> command = ArgumentCaptor.forClass(ExceptionOrderCommand.class);
verify(commands).insert(command.capture());
assertThat(command.getValue().getResultJson()).contains("PROCESSING");
}
@Test
void rejectsStaleVersionWithoutMutation() {
when(commands.findByKey("1001", "START_PROCESSING", "start-2")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order("PENDING", 2));
assertThatThrownBy(() -> service.startProcessing("1001", "9001", "EX-1",
new ExceptionOrderRequests.StartProcessing(1, "start-2", null)))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verify(exceptionOrders, never()).updateById(any(ExceptionOrder.class));
verify(commands, never()).insert(any(ExceptionOrderCommand.class));
}
@Test
void replaysSameCommandWithoutUpdatingAgain() throws Exception {
ExceptionOrderResponses.CommandResult saved = new ExceptionOrderResponses.CommandResult(
1L, "EX-1", "PROCESSING", 1, null, 0, null, false);
ExceptionOrderRequests.StartProcessing request =
new ExceptionOrderRequests.StartProcessing(0, "start-3", null);
ExceptionOrderCommand command = new ExceptionOrderCommand();
command.setRequestHash(hashFor(request));
command.setResultJson(objectMapper.writeValueAsString(saved));
when(commands.findByKey("1001", "START_PROCESSING", "start-3")).thenReturn(command);
var replayed = service.startProcessing("1001", "9001", "EX-1", request);
assertThat(replayed.replayed()).isTrue();
assertThat(replayed.status()).isEqualTo("PROCESSING");
verify(exceptionOrders, never()).lockByNo(any(), any());
}
@Test
void completesWarehouseReturnOnlyAfterInventoryRestoreSucceeds() {
ExceptionOrder order = order("PROCESSING", 1);
order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE");
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L);
order.setDeliveryNo("DL-1");
ExceptionReturnItem source = new ExceptionReturnItem();
source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L);
source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L);
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1");
delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L);
LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30);
when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source));
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt));
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(lastMileOrders.selectList(any())).thenReturn(List.of());
ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓",
List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L,
"PLT-201", 301L, "LOC-301")));
var result = service.complete("1001", "9001", "EX-1", request);
assertThat(result.status()).isEqualTo("COMPLETED");
assertThat(result.inventoryAdjustmentId()).isEqualTo(81L);
assertThat(result.restoredPackages()).isEqualTo(2);
assertThat(delivery.getStatus()).isEqualTo("RETURNED");
verify(evidenceFiles).validate("1001", 1L, 101L);
verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class));
}
private String hashFor(Object request) {
return (String) ReflectionTestUtils.invokeMethod(service, "hash", request);
}
private ExceptionOrder order(String status, long version) {
ExceptionOrder order = new ExceptionOrder();
order.setId(1L); order.setCustomerId("1001"); order.setExceptionNo("EX-1");
order.setStatus(status); order.setVersion(version);
return order;
}
}
@@ -27,6 +27,13 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("expected_loading_at"));
assertTrue(schema.contains("planned_arrival_date"));
assertTrue(schema.contains("creation_mode"));
assertTrue(schema.contains("pallet_type"));
assertTrue(schema.contains("create table if not exists ll_order_command_record"));
assertTrue(schema.contains("create table if not exists ll_order_hold_history"));
assertTrue(schema.contains("create table if not exists ll_order_manual_node"));
assertTrue(schema.contains("create table if not exists ll_order_manual_node_history"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item"));
assertTrue(schema.contains("fba_warehouse_code"));
assertFalse(schema.contains("cabinet_no"));
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.inboundplan;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TailPlanCommandOpenApiContractTest {
@Test
void exposesOnlyLatestManualPlanHoldAndEightRowNodeContracts() throws Exception {
try (var stream = getClass().getClassLoader()
.getResourceAsStream("openapi/overseas-business-v1.yaml")) {
assertTrue(stream != null);
String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:"));
assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:"));
assertFalse(api.contains("plannedWarehouseId"));
assertFalse(api.contains("plannedWarehouseCode"));
assertFalse(api.contains("plannedWarehouseName"));
int manualSchemaStart = api.indexOf(" ManualInboundPlanRequest:");
int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart);
assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart);
assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode"));
assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents())
.anyMatch(component -> "inboundCode".equals(component.getName())));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:"));
assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:"));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:"));
assertTrue(api.contains("minItems: 8"));
assertTrue(api.contains("maxItems: 8"));
assertTrue(api.contains("ACTUAL_ARRIVAL_WAITING_UNLOAD"));
}
}
}
@@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
@@ -19,6 +19,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import java.time.LocalDateTime;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
@@ -61,31 +62,19 @@ class InboundPlanControllerTest {
}
@Test
void platformConversionRequiresAnExplicitCustomer() {
assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("平台生成入库计划必须指定客户");
verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
void platformManualPlanPassesVersionedIdempotentCommand() {
ManualInboundPlanRequest request = manualRequest();
controller.manual(platform(), "manual-key", request);
verify(service).convertManual(request, "manual-key", "7");
}
@Test
void platformConversionPassesTheProvidedLongCustomerId() {
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L,
"193000000000200003");
controller.convert(platform(), request);
verify(service).convert("193000000000200003", 9223372036854775807L);
}
@Test
void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() {
void merchantCannotCreateManualPlan() {
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200");
controller.convert(customer, request);
verify(service).convert("100", 2L);
assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest()))
.isInstanceOf(ResponseStatusException.class);
verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -109,4 +98,9 @@ class InboundPlanControllerTest {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
Set.of("platform:overseas-warehouse:inbound-order:list"));
}
private static ManualInboundPlanRequest manualRequest() {
return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0,
LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓");
}
}
@@ -33,7 +33,7 @@ class InboundPlanPermissionContractTest {
@Test
void conversionUsesCanonicalInboundOrderCreateAuthority() {
Method method = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("convert"))
.filter(candidate -> candidate.getName().equals("manual"))
.findFirst()
.orElseThrow();

Some files were not shown because too many files have changed in this diff Show More