feat: complete tail order controls and terminal drayage

This commit is contained in:
2026-09-07 23:58:18 +08:00
parent f018ca85a9
commit e32fe65fa8
53 changed files with 2566 additions and 87 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;
@@ -137,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")
@@ -227,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());
}
}
@@ -66,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 " +
@@ -124,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 " +
@@ -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")),
@@ -221,6 +224,7 @@ 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) {
@@ -351,6 +355,23 @@ public class DispatchService {
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);
@@ -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,
@@ -32,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,
@@ -39,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;
}
@@ -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,14 +44,19 @@ 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")
@@ -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
) {
}
@@ -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(
@@ -21,7 +22,14 @@ 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 record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
@@ -32,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,25 @@
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 Long plannedWarehouseId,
@NotBlank @Size(max = 128) String plannedWarehouseCode,
@NotBlank @Size(max = 255) String plannedWarehouseName,
@NotNull LocalDate plannedArrivalDate,
@NotBlank @Size(max = 128) String inboundCode,
@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;
@@ -17,6 +17,7 @@ public class InboundPlanPallet {
private Long planId;
private String palletNo;
private String sourcePalletNo;
private String palletType;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
@@ -36,12 +36,12 @@ public interface InboundPlanOrderMapper {
"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.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.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);
@@ -59,6 +59,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,
@@ -20,5 +20,19 @@ public record SourceOrderSnapshot(
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, 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, recommendedPalletCount, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
destinationCount, destinationMismatchCount, false, 0);
}
}
@@ -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,64 @@ 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<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(trimToNull(order.inboundCode()), trimToNull(request.inboundCode()))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "入仓码与尾程订单冻结事实不一致");
}
WarehouseResourceGateway.WarehouseReference warehouse =
warehouseGateway.requireActiveSharedWarehouse(request.plannedWarehouseId());
if (!Objects.equals(String.valueOf(warehouse.warehouseId()), trimToNull(order.overseasWarehouseId()))
|| !Objects.equals(warehouse.warehouseCode(), request.plannedWarehouseCode().trim())
|| !Objects.equals(warehouse.warehouseName(), request.plannedWarehouseName().trim())
|| !Objects.equals(warehouse.warehouseCode(), trimToNull(order.overseasWarehouseCode()))
|| !Objects.equals(warehouse.warehouseName(), trimToNull(order.overseasWarehouseName()))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "计划入仓仓库不存在、已停用或与订单预分配事实不一致");
}
validateOrders(snapshots);
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, request);
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 +180,12 @@ public class InboundPlanService {
return detail(customerId, existing);
}
validateOrders(orders);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null);
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets) {
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual) {
String containerNo = requiredContainerNo(orders.get(0).containerNo());
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
@@ -136,6 +204,13 @@ public class InboundPlanService {
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));
@@ -145,16 +220,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);
@@ -171,7 +250,11 @@ 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(),
@@ -248,7 +331,7 @@ public class InboundPlanService {
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)));
wholePallets.get(index), null));
}
return List.copyOf(created);
}
@@ -257,6 +340,43 @@ public class InboundPlanService {
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);
@@ -395,7 +515,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());
}
@@ -533,12 +653,14 @@ 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 BigDecimal volume(SourceOrderSnapshot order) {
@@ -554,4 +676,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,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);
}
@@ -146,8 +146,27 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@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);
@@ -156,7 +175,7 @@ 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);
@@ -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,7 @@ 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;
@@ -25,6 +27,7 @@ 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;
@@ -54,12 +57,16 @@ 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;
@@ -243,7 +250,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(),
@@ -552,7 +567,9 @@ public class LastlegOrderService {
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),
@@ -70,6 +70,10 @@ CREATE TABLE IF NOT EXISTS ll_order (
container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态',
abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是',
hold_reason VARCHAR(500) NULL COMMENT '当前暂扣原因;解除后为空',
manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用单票人工计划节点覆盖:0否1是,启用后不再继承柜级节点',
version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数',
@@ -110,6 +114,14 @@ CREATE TABLE IF NOT EXISTS ll_order (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained'));
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable'));
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason'));
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override'));
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
CREATE TABLE IF NOT EXISTS ll_order_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
@@ -179,6 +191,47 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
CREATE TABLE IF NOT EXISTS ll_order_command_record (
id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES',
operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', result_ref VARCHAR(128) NULL COMMENT '结果业务引用',
result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', result_version INT NOT NULL COMMENT '命令成功后的订单版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key),
KEY idx_ll_order_command_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录';
CREATE TABLE IF NOT EXISTS ll_order_hold_history (
id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是',
chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因',
order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key),
KEY idx_ll_order_hold_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史';
CREATE TABLE IF NOT EXISTS ll_order_manual_node (
id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离',
node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL',
order_version INT NOT NULL COMMENT '最近编辑后的订单版本', updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID',
updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at)
) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实';
CREATE TABLE IF NOT EXISTS ll_order_manual_node_history (
id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照',
order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key),
KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史';
CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation (
id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID',
@@ -504,6 +557,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NOT NULL DEFAULT 193000000000200003 COMMENT '平台预分配的海外实际收货共享物理仓ID',
planned_arrival_date DATE NULL COMMENT '人工计划预计到仓日期',
pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型',
assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是',
merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是',
urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是',
creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工',
plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注',
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量',
@@ -557,6 +617,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码',
source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照',
pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
@@ -588,6 +649,20 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation (
ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id'));
PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
ALTER TABLE ll_inbound_plan
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)',
MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号',
@@ -640,6 +715,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外仓实物托盘ID'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外仓库位ID'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''海外仓资源锁ID'''));
@@ -192,25 +192,46 @@ paths:
responses:
'200': { description: 新增后的费用明细, content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeApiResponse' } } } }
'404': { description: 尾程订单不存在或不属于操作客户 }
/api/platform/v1/last-leg/orders/{id}/detained:
/api/platform/v1/last-leg/orders/{id}/hold:
put:
summary: 设置或解除尾程订单暂扣
description: 权限 platform:overseas-warehouse:last-mile-order:edit;库存同步转入或释放暂扣,并通过跨区事件回写国内入仓单。
description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [detained, remark]
properties:
detained: { type: boolean }
remark: { type: string, minLength: 1, maxLength: 500 }
responses: { '200': { description: 暂扣状态已更新 } }
schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' }
responses:
'200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } }
'409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 }
/api/platform/v1/last-leg/orders/{id}/manual-nodes:
get:
summary: 查询尾程订单人工展示节点
description: 权限 platform:overseas-warehouse:last-mile-order:list;人工覆盖开启后返回完整且固定顺序的8行快照,行值允许为空。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
responses: { '200': { description: 完整人工展示节点快照 } }
put:
summary: 永久启用并保存尾程订单人工展示节点
description: 权限 platform:overseas-warehouse:last-mile-order:edit;提交完整8行快照且至少一个时间非空,使用订单版本和 Idempotency-Key;保存后 nodeSource 永久为 MANUAL,不回写柜节点,也不提供清除或恢复继承接口。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LastlegOrderManualNodesRequest' } } }
responses:
'200': { description: 更新后的完整8行人工展示节点快照 }
'409': { description: 订单版本或幂等键冲突 }
/api/platform/v1/last-leg/orders/{id}/pallet-merge-required:
put:
summary: 设置尾程订单合托标记
@@ -381,22 +402,22 @@ paths:
responses:
'200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }
'404': { description: 入库订单不存在或不属于指定客户 }
/api/platform/v1/last-leg/inbound-plans/convert:
/api/platform/v1/last-leg/inbound-plans/manual:
post:
summary: 平台生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。
summary: 平台按单票手工生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
security: [{ bearerAuth: [] }]
parameters:
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [customerId, orderId]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } }
schema: { $ref: '#/components/schemas/ManualInboundPlanRequest' }
responses:
'200': { description: 统一 ApiResponse 包装的入库计划详情 }
'400': { description: 客户、入仓码、仓库主数据或请求字段无效 }
'409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 }
/api/platform/v1/last-leg/inbound-plans/convert-container:
post:
summary: 按整柜批量生成入库计划
@@ -1796,6 +1817,61 @@ components:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } }
LastlegOrderHoldRequest:
type: object
required: [held, chargeable, reason, expectedVersion]
properties:
held: { type: boolean }
chargeable: { type: boolean, description: 仅 held=true 时生效;解除暂扣后固定为 false }
reason: { type: string, minLength: 1, maxLength: 500 }
expectedVersion: { type: integer, minimum: 0 }
LastlegOrderHoldResponse:
type: object
required: [orderId, held, chargeable, version]
properties:
orderId: { type: integer, format: int64 }
held: { type: boolean }
chargeable: { type: boolean }
reason: { type: string, nullable: true }
version: { type: integer, minimum: 0 }
LastlegOrderManualNodeCode:
type: string
enum: [DOMESTIC_CUSTOMS_COMPLETED, ESTIMATED_DEPARTURE, ACTUAL_DEPARTURE, ESTIMATED_ARRIVAL, OVERSEAS_CUSTOMS_COMPLETED, ACTUAL_ARRIVAL_WAITING_UNLOAD, PICKUP_APPOINTED, UNLOADED]
LastlegOrderManualNode:
type: object
required: [nodeCode]
properties:
nodeCode: { $ref: '#/components/schemas/LastlegOrderManualNodeCode' }
nodeAt: { type: string, format: date-time, nullable: true }
LastlegOrderManualNodesRequest:
type: object
required: [expectedVersion, nodes]
properties:
expectedVersion: { type: integer, minimum: 0 }
nodes:
type: array
minItems: 8
maxItems: 8
description: 必须包含8个枚举节点且各出现一次,至少一个 nodeAt 非空。
items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
ManualInboundPlanRequest:
type: object
required: [customerId, orderId, expectedOrderVersion, plannedWarehouseId, plannedWarehouseCode, plannedWarehouseName, plannedArrivalDate, inboundCode, palletCount, palletType, assigned, mergeRequired, urgent]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
orderId: { type: integer, format: int64, minimum: 1 }
expectedOrderVersion: { type: integer, minimum: 0 }
plannedWarehouseId: { type: integer, format: int64, minimum: 1 }
plannedWarehouseCode: { type: string, minLength: 1, maxLength: 64 }
plannedWarehouseName: { type: string, minLength: 1, maxLength: 255 }
plannedArrivalDate: { type: string, format: date }
inboundCode: { type: string, minLength: 1, maxLength: 128 }
palletCount: { type: integer, minimum: 1, maximum: 999 }
palletType: { type: string, minLength: 1, maxLength: 64 }
assigned: { type: boolean }
mergeRequired: { type: boolean }
urgent: { type: boolean }
remark: { type: string, nullable: true, maxLength: 1000 }
PageMetadata:
type: object
required: [page, size, total, pages]
@@ -2022,6 +2098,13 @@ components:
totalVolumeCbm: { type: number }
expectedPackageCount: { type: integer }
pickedPackageCount: { type: integer }
plannedArrivalDate: { type: string, format: date, nullable: true }
palletType: { type: string, nullable: true }
assigned: { type: boolean }
mergeRequired: { type: boolean }
urgent: { type: boolean }
creationMode: { type: string, enum: [AUTO, MANUAL] }
remark: { type: string, nullable: true }
lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } }
pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } }
allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } }
@@ -2044,6 +2127,7 @@ components:
properties:
id: { type: string, pattern: '^[0-9]+$' }
palletNo: { type: string }
palletType: { type: string, nullable: true }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
@@ -9,6 +9,8 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LastlegOrderCommandService;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
import org.junit.jupiter.api.Test;
@@ -32,6 +34,7 @@ import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
// Controller contract tests intentionally instantiate the controller without a Spring context here.
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@@ -59,6 +62,23 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void holdCommandPassesVersionedIdempotentRequestToSingleOrderService() {
var request = new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 2);
controller.hold(user(0L, "PLATFORM"), "100", 11L, "hold-11", request);
verify(commandService).hold("100", 11L, "hold-11", request, "1", "tester");
}
@Test
void manualNodeQueryUsesOrderScopeRatherThanContainerScope() {
controller.manualNodes(user(0L, "PLATFORM"), "100", 11L);
verify(commandService).manualNodes("100", 11L);
verify(service, never()).containerProgressDetail(org.mockito.ArgumentMatchers.anyString());
}
@Test
void platformOperatorCanQueryCrossCustomerContainerSummaries() {
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null);
@@ -130,6 +150,7 @@ class LastlegOrderControllerTest {
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class));
ReflectionTestUtils.setField(controller, "orderService", service);
ReflectionTestUtils.setField(controller, "commandService", commandService);
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
return controller;
}
@@ -18,6 +18,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -52,6 +54,7 @@ class DispatchServiceTest {
@Mock DispatchDraftMapper drafts;
@Mock DispatchDraftContainerMapper draftContainers;
@Mock DispatchPdaTaskMapper pdaTasks;
@Mock LastlegOrderMapper tailOrders;
private DispatchService service;
@BeforeEach
@@ -64,8 +67,26 @@ class DispatchServiceTest {
ReflectionTestUtils.setField(service, "drafts", drafts);
ReflectionTestUtils.setField(service, "draftContainers", draftContainers);
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
lenient().when(tailOrders.selectList(any())).thenReturn(List.of());
}
@Test
void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
LastlegOrder held = new LastlegOrder();
held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true);
when(tailOrders.selectList(any())).thenReturn(List.of(held));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
null, null, null)));
assertEquals(409, exception.getStatusCode().value());
assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason());
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
@@ -27,6 +27,13 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("expected_loading_at"));
assertTrue(schema.contains("planned_arrival_date"));
assertTrue(schema.contains("creation_mode"));
assertTrue(schema.contains("pallet_type"));
assertTrue(schema.contains("create table if not exists ll_order_command_record"));
assertTrue(schema.contains("create table if not exists ll_order_hold_history"));
assertTrue(schema.contains("create table if not exists ll_order_manual_node"));
assertTrue(schema.contains("create table if not exists ll_order_manual_node_history"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item"));
assertTrue(schema.contains("fba_warehouse_code"));
assertFalse(schema.contains("cabinet_no"));
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.inboundplan;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TailPlanCommandOpenApiContractTest {
@Test
void exposesOnlyLatestManualPlanHoldAndEightRowNodeContracts() throws Exception {
try (var stream = getClass().getClassLoader()
.getResourceAsStream("openapi/overseas-business-v1.yaml")) {
assertTrue(stream != null);
String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:"));
assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:"));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:"));
assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:"));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:"));
assertTrue(api.contains("minItems: 8"));
assertTrue(api.contains("maxItems: 8"));
assertTrue(api.contains("ACTUAL_ARRIVAL_WAITING_UNLOAD"));
}
}
}
@@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
@@ -19,6 +19,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import java.time.LocalDateTime;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
@@ -61,31 +62,19 @@ class InboundPlanControllerTest {
}
@Test
void platformConversionRequiresAnExplicitCustomer() {
assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("平台生成入库计划必须指定客户");
verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
void platformManualPlanPassesVersionedIdempotentCommand() {
ManualInboundPlanRequest request = manualRequest();
controller.manual(platform(), "manual-key", request);
verify(service).convertManual(request, "manual-key", "7");
}
@Test
void platformConversionPassesTheProvidedLongCustomerId() {
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L,
"193000000000200003");
controller.convert(platform(), request);
verify(service).convert("193000000000200003", 9223372036854775807L);
}
@Test
void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() {
void merchantCannotCreateManualPlan() {
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200");
controller.convert(customer, request);
verify(service).convert("100", 2L);
assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest()))
.isInstanceOf(ResponseStatusException.class);
verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -109,4 +98,10 @@ class InboundPlanControllerTest {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
Set.of("platform:overseas-warehouse:inbound-order:list"));
}
private static ManualInboundPlanRequest manualRequest() {
return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0,
193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 8),
"FBA-001", 2, "STANDARD", false, true, true, "加急入仓");
}
}
@@ -33,7 +33,7 @@ class InboundPlanPermissionContractTest {
@Test
void conversionUsesCanonicalInboundOrderCreateAuthority() {
Method method = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("convert"))
.filter(candidate -> candidate.getName().equals("manual"))
.findFirst()
.orElseThrow();
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
@@ -15,6 +16,9 @@ import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -28,11 +32,13 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.time.LocalDateTime;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -47,6 +53,8 @@ class InboundPlanServiceTest {
@Mock InboundPlanOrderMapper orderMapper;
@Mock InventoryBalanceMapper inventoryBalanceMapper;
@Mock DropshipOrderService dropshipOrders;
@Mock WarehouseResourceGateway warehouseGateway;
@Mock LastlegOrderCommandRecordMapper commandMapper;
private InboundPlanService service;
@@ -60,6 +68,9 @@ class InboundPlanServiceTest {
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
ReflectionTestUtils.setField(service, "inventoryBalanceMapper", inventoryBalanceMapper);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
ReflectionTestUtils.setField(service, "commandMapper", commandMapper);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
@@ -112,6 +123,52 @@ class InboundPlanServiceTest {
assertEquals("洛杉矶仓", result.overseasWarehouseName());
}
@Test
void manuallyCreatesOneIndependentPlanWithFrozenMetadata() {
SourceOrderSnapshot source = order(2L, "RK-2", "1.0000", "WH-A");
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(
193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE"));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()))
.thenReturn(1);
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0,
193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 15),
"RK-2", 2, "STANDARD", true, true, true, "优先收货");
var result = service.convertManual(request, "manual-2", "900");
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
verify(planMapper).insert(plan.capture());
assertEquals(2L, plan.getValue().getSourceOrderId());
assertEquals(LocalDate.of(2026, 9, 15), plan.getValue().getPlannedArrivalDate());
assertEquals("STANDARD", plan.getValue().getPalletType());
assertEquals("MANUAL", plan.getValue().getCreationMode());
assertEquals(2, result.palletCount());
verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class));
verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any());
verify(commandMapper).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
verify(orderMapper, never()).lockContainerOrders(any());
}
@Test
void refusesManualPlanForHeldOrderBeforeWriting() {
SourceOrderSnapshot held = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, new BigDecimal("1.0000"), 10, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
null, "WH-A", "仓A", 1, 0, true, 5);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 5, 193000000000200003L, "US-LAX-01", "洛杉矶仓",
LocalDate.of(2026, 9, 15), "RK-2", 1, "STANDARD", false, false, false, null),
"manual-held", "900"));
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
void convertsAConsistentDestinationAssignedOrder() {
SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
@@ -0,0 +1,161 @@
package com.baiyue.lastleg.service;
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.LastlegOrderManualNodeHistory;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
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.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LastlegOrderCommandServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderCommandRecordMapper commands;
@Mock LastlegOrderHoldHistoryMapper holdHistory;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Mock LastlegOrderOperationLogMapper operationLogs;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
LastlegOrderCommandService service;
@BeforeEach
void setUp() {
service = new LastlegOrderCommandService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "holdHistory", holdHistory);
ReflectionTestUtils.setField(service, "manualNodes", manualNodes);
ReflectionTestUtils.setField(service, "manualNodeHistory", manualNodeHistory);
ReflectionTestUtils.setField(service, "operationLogs", operationLogs);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
}
@Test
void holdsOnlySelectedOrderWithVersionAndImmutableHistory() {
LastlegOrder order = order(11L, 3);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
when(orders.updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3),
any(LocalDateTime.class))).thenReturn(1);
var response = service.hold("100", 11L, "hold-11",
new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 3), "900", "管理员");
assertTrue(response.held());
assertTrue(response.chargeable());
assertEquals(4, response.version());
verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any());
verify(holdHistory).insert(any(LastlegOrderHoldHistory.class));
verify(commands).insert(any(LastlegOrderCommandRecord.class));
verify(orders, never()).lockContainerOrders(any());
}
@Test
void rejectsStaleHoldWithoutAnyWrite() {
LastlegOrder order = order(11L, 4);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.hold("100", 11L,
"hold-stale", new LastlegOrderCommandDtos.HoldRequest(true, false, "待核对", 3), "900", "管理员"));
assertEquals(409, error.getStatusCode().value());
verify(orders, never()).updateHold(anyString(), anyLong(), anyBoolean(), anyBoolean(),
nullable(String.class), anyInt(), any(LocalDateTime.class));
verify(commands, never()).insert(any(LastlegOrderCommandRecord.class));
}
@Test
void savesExactEightRowManualSnapshotInFrozenDisplayOrder() {
LastlegOrder order = order(12L, 0);
when(orders.selectById(12L)).thenReturn(order);
when(orders.lockById("100", 12L)).thenReturn(order);
when(orders.updateManualNodes(eq("100"), eq(12L), any(), any(), any(), eq(0), any())).thenReturn(1);
LocalDateTime filled = LocalDateTime.of(2026, 9, 8, 10, 0);
List<LastlegOrderCommandDtos.ManualNode> nodes = List.of(
node("UNLOADED", null), node("PICKUP_APPOINTED", null),
node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null), node("OVERSEAS_CUSTOMS_COMPLETED", null),
node("ESTIMATED_ARRIVAL", filled), node("ACTUAL_DEPARTURE", null),
node("ESTIMATED_DEPARTURE", null), node("DOMESTIC_CUSTOMS_COMPLETED", null));
var response = service.saveManualNodes("100", 12L, "nodes-12",
new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员");
assertTrue(response.manualOverrideEnabled());
assertEquals(8, response.nodes().size());
assertEquals("DOMESTIC_CUSTOMS_COMPLETED", response.nodes().get(0).nodeCode());
assertEquals("UNLOADED", response.nodes().get(7).nodeCode());
ArgumentCaptor<LastlegOrderManualNode> rows = ArgumentCaptor.forClass(LastlegOrderManualNode.class);
verify(manualNodes, org.mockito.Mockito.times(8)).insert(rows.capture());
assertEquals(8, rows.getAllValues().size());
verify(manualNodeHistory).insert(any(LastlegOrderManualNodeHistory.class));
}
@Test
void rejectsIncompleteOrFullyEmptyManualSnapshot() {
LastlegOrder order = order(12L, 0);
when(orders.selectById(12L)).thenReturn(order);
when(orders.lockById("100", 12L)).thenReturn(order);
List<LastlegOrderCommandDtos.ManualNode> nodes = List.of(
node("DOMESTIC_CUSTOMS_COMPLETED", null), node("ESTIMATED_DEPARTURE", null),
node("ACTUAL_DEPARTURE", null), node("ESTIMATED_ARRIVAL", null),
node("OVERSEAS_CUSTOMS_COMPLETED", null), node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null),
node("PICKUP_APPOINTED", null), node("UNLOADED", null));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
service.saveManualNodes("100", 12L, "nodes-empty",
new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员"));
assertEquals(400, error.getStatusCode().value());
verify(orders, never()).updateManualNodes(anyString(), anyLong(), nullable(LocalDateTime.class),
nullable(LocalDateTime.class), nullable(LocalDateTime.class), anyInt(), any(LocalDateTime.class));
}
private LastlegOrderCommandDtos.ManualNode node(String code, LocalDateTime time) {
return new LastlegOrderCommandDtos.ManualNode(code, time);
}
private LastlegOrder order(long id, int version) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
order.setCustomerId("100");
order.setOrderNo("LL-" + id);
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setDetained(false);
order.setVersion(version);
return order;
}
}
@@ -14,6 +14,7 @@ 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.inboundplan.pda.gateway.WarehouseResourceGateway;
import org.junit.jupiter.api.BeforeEach;
@@ -54,6 +55,7 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderFeeMapper fees;
@Mock LastlegOrderOperationLogMapper operations;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock InboundPlanCartonMapper cartons;
@Mock FbaWarehouseValidator fbaWarehouses;
@Mock InventoryCommandService inventory;
@@ -70,6 +72,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "feeMapper", fees);
ReflectionTestUtils.setField(service, "operationLogMapper", operations);
ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses);
lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation ->
@@ -133,6 +136,7 @@ class LastlegOrderServiceTest {
when(histories.selectList(any())).thenReturn(List.of());
when(fees.selectList(any())).thenReturn(List.of());
when(operations.selectList(any())).thenReturn(List.of());
when(manualNodes.selectList(any())).thenReturn(List.of());
var response = service.detail("100", 11L);