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(),