Merge test fixes into dev dropship flow
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改,生成资格只由 `allPlansReady/pendingPlanCount` 表达;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
||||
@@ -28,7 +28,7 @@
|
||||
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ public record ContainerPlanProgressSummary(
|
||||
long planCount,
|
||||
long plannedInboundCodeCount,
|
||||
long pendingPlanCount,
|
||||
long resourcePendingPlanCount,
|
||||
LocalDateTime plannedArrivalAt,
|
||||
boolean allPlansReady,
|
||||
String blockingReason
|
||||
@@ -30,6 +31,6 @@ public record ContainerPlanProgressSummary(
|
||||
String blockingReason) {
|
||||
this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null,
|
||||
totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount,
|
||||
plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason);
|
||||
plannedInboundCodeCount, pendingPlanCount, 0, plannedArrivalAt, allPlansReady, blockingReason);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -29,7 +29,6 @@ public class OverseasFeeBusinessGuard {
|
||||
(result, rowNum) -> new ContainerCustomerOption(
|
||||
result.getString("customer_id"), result.getString("customer_name")),
|
||||
String.valueOf(containerBookingId));
|
||||
if (customers.isEmpty()) throw bad("真实货柜不存在或尚无尾程订单");
|
||||
return customers;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -49,7 +49,7 @@ public class InboundPlanController {
|
||||
|
||||
@PostMapping("/manual")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
|
||||
@Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: "
|
||||
@Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。整柜最后一票计划保存后,服务端自动生成并绑定全柜托盘码。Required authority: "
|
||||
+ OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
public ApiResponse<InboundPlanResponse> manual(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@@ -59,7 +59,8 @@ public class InboundPlanController {
|
||||
}
|
||||
convertScope(user, request.customerId());
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId));
|
||||
return ApiResponse.success(containerConversion.convertManualAndAutoMaterialize(
|
||||
request, idempotencyKey, operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/convert-container")
|
||||
|
||||
+11
-2
@@ -25,7 +25,8 @@ public interface InboundPlanOrderMapper {
|
||||
java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode,
|
||||
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
|
||||
boolean detained, int version, String planNo, String planStatus, Integer plannedPalletCount
|
||||
boolean detained, int version, String planNo, String planStatus,
|
||||
Integer plannedPalletCount, Integer materializedPalletCount
|
||||
) { }
|
||||
|
||||
@Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," +
|
||||
@@ -40,7 +41,10 @@ public interface InboundPlanOrderMapper {
|
||||
"COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," +
|
||||
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " +
|
||||
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
|
||||
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus,p.pallet_count AS plannedPalletCount " +
|
||||
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
|
||||
"p.pallet_count AS plannedPalletCount," +
|
||||
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
|
||||
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount " +
|
||||
"FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
|
||||
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
|
||||
List<ContainerPreviewOrderRow> containerPreviewOrders(
|
||||
@@ -63,6 +67,11 @@ public interface InboundPlanOrderMapper {
|
||||
@Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT source_container_booking_id FROM ll_order " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findSourceContainerBookingId(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
|
||||
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
|
||||
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
|
||||
|
||||
+19
-1
@@ -128,6 +128,7 @@ public class ReceivingContainerService {
|
||||
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
|
||||
requireReadyContainer(containerNo, overseasWarehouseId, false);
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
requireMaterializedPlans(containerPlans);
|
||||
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||
requireSessionWarehouse(current, overseasWarehouseId);
|
||||
@@ -158,6 +159,7 @@ public class ReceivingContainerService {
|
||||
return result;
|
||||
}
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
requireMaterializedPlans(containerPlans);
|
||||
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
|
||||
String activeOperatorKey = activeOperatorKey(operatorKey);
|
||||
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
|
||||
@@ -210,10 +212,11 @@ public class ReceivingContainerService {
|
||||
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
|
||||
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList();
|
||||
photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId));
|
||||
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
|
||||
requireMaterializedPlans(containerPlans);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
|
||||
session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session);
|
||||
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
|
||||
for (InboundPlan plan : containerPlans) {
|
||||
workflow.ensureSignedTask(plan.getCustomerId(), plan, operatorKey, operatorName, photoFileIds, now);
|
||||
}
|
||||
@@ -705,6 +708,21 @@ public class ReceivingContainerService {
|
||||
|| "LOCKED".equals(pallet.getAssignmentStatus()));
|
||||
}
|
||||
|
||||
private void requireMaterializedPlans(List<InboundPlan> containerPlans) {
|
||||
for (InboundPlan plan : containerPlans) {
|
||||
int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount();
|
||||
long materialized = pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId())
|
||||
.eq(InboundPlanPallet::getPlanId, plan.getId())
|
||||
.isNotNull(InboundPlanPallet::getWarehousePalletId)
|
||||
.isNotNull(InboundPlanPallet::getWarehouseLocationId)
|
||||
.isNotNull(InboundPlanPallet::getWarehouseResourceLockId));
|
||||
if (expected <= 0 || materialized < expected) {
|
||||
throw conflict("入库计划尚未完整绑定真实托盘,请先在平台按柜生成入库计划");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) {
|
||||
if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped");
|
||||
long actualWarehouse;
|
||||
|
||||
+119
-11
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
@@ -30,6 +31,10 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -42,6 +47,35 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* Saves one manually adjusted plan, then materializes every plan in the container as soon as
|
||||
* the last logical plan is complete. The manual endpoint is therefore never a separate pallet
|
||||
* creation workflow: for a one-order container this happens immediately; for a multi-order
|
||||
* container the final manual save closes the whole container.
|
||||
*/
|
||||
public InboundPlanResponse convertManualAndAutoMaterialize(ManualInboundPlanRequest request,
|
||||
String idempotencyKey,
|
||||
String operatorId) {
|
||||
InboundPlanResponse manual = plans.convertManual(request, idempotencyKey, operatorId);
|
||||
String bookingId = plans.sourceContainerBookingId(manual.customerId(), manual.sourceOrderId());
|
||||
ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId);
|
||||
List<ContainerInboundPlanPreviewResponse.Order> orders = preview.groups().stream()
|
||||
.flatMap(group -> group.orders().stream()).toList();
|
||||
boolean allLogicalPlansComplete = !orders.isEmpty() && orders.stream().allMatch(order ->
|
||||
"PLANNED".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness()));
|
||||
boolean needsMaterialization = orders.stream()
|
||||
.anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness()));
|
||||
if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual;
|
||||
|
||||
List<InboundPlanResponse> materialized = confirm(new ConvertContainerInboundPlanRequest(
|
||||
bookingId, preview.snapshotVersion(), preview.snapshotHash()),
|
||||
"AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey)));
|
||||
return materialized.stream()
|
||||
.filter(plan -> Objects.equals(plan.customerId(), manual.customerId())
|
||||
&& Objects.equals(plan.sourceOrderId(), manual.sourceOrderId()))
|
||||
.findFirst().orElse(manual);
|
||||
}
|
||||
|
||||
public List<InboundPlanResponse> confirm(ConvertContainerInboundPlanRequest request, String idempotencyKey) {
|
||||
String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空");
|
||||
String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符");
|
||||
@@ -116,7 +150,7 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
|
||||
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
|
||||
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
|
||||
item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD",
|
||||
item.businessKey(), item.planNo(), item.globalSequenceNo(), "WOOD",
|
||||
item.fbaWarehouseCode(), item.palletCode())).toList());
|
||||
WarehouseResourceGateway.PalletBatchResponse reservation;
|
||||
try {
|
||||
@@ -201,21 +235,76 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
catch (RuntimeException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
|
||||
}
|
||||
List<PreparedItem> items = new ArrayList<>();
|
||||
int globalSequence = 0;
|
||||
for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream()
|
||||
.flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) {
|
||||
long planId = IdWorker.getId();
|
||||
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
|
||||
List<PreparedOrder> preparedOrders = new ArrayList<>();
|
||||
Map<String, String> prefixByFba = new LinkedHashMap<>();
|
||||
Map<String, Integer> nextIndexByFba = new LinkedHashMap<>();
|
||||
Set<String> usedPalletCodes = new HashSet<>();
|
||||
List<ContainerInboundPlanPreviewResponse.Order> convertibleOrders = preview.groups().stream()
|
||||
.flatMap(group -> group.orders().stream())
|
||||
.filter(item -> "READY".equals(item.readiness()) || "RESOURCE_PENDING".equals(item.readiness()))
|
||||
.toList();
|
||||
for (ContainerInboundPlanPreviewResponse.Order order : convertibleOrders) {
|
||||
List<String> sourcePalletNos = plans.sourcePalletNos(
|
||||
order.customerId(), Long.parseLong(order.orderId()), order.orderType());
|
||||
int targetPalletCount = "RESOURCE_PENDING".equals(order.readiness())
|
||||
? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
|
||||
: Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
|
||||
if (sourcePalletNos.size() > targetPalletCount) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量");
|
||||
}
|
||||
String fbaCode = trimToNull(order.fbaWarehouseCode());
|
||||
if (fbaCode != null) {
|
||||
for (String palletCode : sourcePalletNos) {
|
||||
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
|
||||
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
|
||||
if (previousPrefix != null && !previousPrefix.equals(parts.prefix())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一 FBA 仓的头程托盘码内部编码不一致");
|
||||
}
|
||||
nextIndexByFba.merge(fbaCode, parts.index(), Math::max);
|
||||
if (!usedPalletCodes.add(palletCode)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码存在重复");
|
||||
}
|
||||
}
|
||||
if (sourcePalletNos.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照");
|
||||
}
|
||||
}
|
||||
InboundPlanService.PlanIdentity existing = "RESOURCE_PENDING".equals(order.readiness())
|
||||
? plans.existingPlanIdentity(order.customerId(), Long.parseLong(order.orderId())) : null;
|
||||
long planId = existing == null ? IdWorker.getId() : existing.planId();
|
||||
String planNo = existing == null
|
||||
? numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId) : existing.planNo();
|
||||
if (existing != null && existing.palletCount() != targetPalletCount) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级预览已不一致");
|
||||
}
|
||||
preparedOrders.add(new PreparedOrder(order, planId, planNo, targetPalletCount,
|
||||
List.copyOf(sourcePalletNos)));
|
||||
}
|
||||
|
||||
List<PreparedItem> items = new ArrayList<>();
|
||||
int globalSequence = 0;
|
||||
for (PreparedOrder preparedOrder : preparedOrders) {
|
||||
ContainerInboundPlanPreviewResponse.Order order = preparedOrder.order();
|
||||
List<String> sourcePalletNos = new ArrayList<>(preparedOrder.sourcePalletNos());
|
||||
boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null
|
||||
&& !"ONE_CLICK_DROPSHIP".equals(order.orderType());
|
||||
if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致");
|
||||
if (!warehouseGeneratedPallets && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
|
||||
String fbaCode = order.fbaWarehouseCode().trim();
|
||||
String prefix = prefixByFba.get(fbaCode);
|
||||
while (sourcePalletNos.size() < preparedOrder.targetPalletCount()) {
|
||||
int next = nextIndexByFba.compute(fbaCode, (ignored, current) -> current == null ? 1 : current + 1);
|
||||
String palletCode = prefix + "-" + String.format("%03d", next);
|
||||
if (!usedPalletCodes.add(palletCode)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "补生成托盘码发生重复");
|
||||
}
|
||||
sourcePalletNos.add(palletCode);
|
||||
}
|
||||
} else if (!warehouseGeneratedPallets && sourcePalletNos.size() != preparedOrder.targetPalletCount()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "一件代发托盘码数量与柜级预览不一致");
|
||||
}
|
||||
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
|
||||
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
|
||||
for (int localSequence = 1; localSequence <= preparedOrder.targetPalletCount(); localSequence++) {
|
||||
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), preparedOrder.planId(),
|
||||
preparedOrder.planNo(),
|
||||
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
|
||||
trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets
|
||||
? null : sourcePalletNos.get(localSequence - 1)));
|
||||
@@ -379,7 +468,26 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
}
|
||||
|
||||
public record Preparation(long warehouseId, List<PreparedItem> items) { }
|
||||
private record PreparedOrder(ContainerInboundPlanPreviewResponse.Order order, long planId, String planNo,
|
||||
int targetPalletCount, List<String> sourcePalletNos) { }
|
||||
public record PreparedItem(String customerId, long orderId, long planId, String planNo,
|
||||
int localSequenceNo, int globalSequenceNo, String businessKey,
|
||||
String fbaWarehouseCode, String palletCode) { }
|
||||
|
||||
private static PalletCodeParts palletCodeParts(String containerNo, String palletCode) {
|
||||
String stableContainerNo = trimToNull(containerNo);
|
||||
String stablePalletCode = trimToNull(palletCode);
|
||||
if (stableContainerNo == null || stablePalletCode == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "集装箱号或头程托盘码缺失");
|
||||
}
|
||||
Matcher matcher = Pattern.compile("^(" + Pattern.quote(stableContainerNo) + "-.+)-(\\d{3})$")
|
||||
.matcher(stablePalletCode);
|
||||
if (!matcher.matches()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"头程托盘码不符合‘集装箱号-FBA内部编码-下标’规则");
|
||||
}
|
||||
return new PalletCodeParts(matcher.group(1), Integer.parseInt(matcher.group(2)));
|
||||
}
|
||||
|
||||
private record PalletCodeParts(String prefix, int index) { }
|
||||
}
|
||||
|
||||
+112
-8
@@ -76,6 +76,15 @@ public class InboundPlanService {
|
||||
: sourcePalletMapper.palletNos(customerId, orderId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public String sourceContainerBookingId(String customerId, long orderId) {
|
||||
String bookingId = trimToNull(orderMapper.findSourceContainerBookingId(customerId, orderId));
|
||||
if (bookingId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜");
|
||||
}
|
||||
return bookingId;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey,
|
||||
String operatorId) {
|
||||
@@ -121,6 +130,14 @@ public class InboundPlanService {
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||
requiredReceivingWarehouse(containerWarehouseId);
|
||||
validateOrders(snapshots);
|
||||
int minimumPalletCount = suggestedPalletCount(order);
|
||||
int sourcePalletCount = Objects.requireNonNullElse(
|
||||
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
|
||||
minimumPalletCount = Math.max(minimumPalletCount, sourcePalletCount);
|
||||
if (request.palletCount() < minimumPalletCount) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
|
||||
}
|
||||
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
|
||||
request, receivingWarehouse);
|
||||
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
|
||||
@@ -186,9 +203,9 @@ public class InboundPlanService {
|
||||
ContainerInboundPlanPreviewResponse.Order first = items.get(0);
|
||||
return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(),
|
||||
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
||||
items.size(), (int) items.stream().filter(item -> "READY".equals(item.readiness())).count(),
|
||||
items.stream().filter(item -> "READY".equals(item.readiness()))
|
||||
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
|
||||
items.size(), (int) items.stream().filter(InboundPlanService::requiresContainerConversion).count(),
|
||||
items.stream().filter(InboundPlanService::requiresContainerConversion)
|
||||
.map(InboundPlanService::pendingPalletCount)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
|
||||
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
|
||||
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
|
||||
@@ -199,9 +216,9 @@ public class InboundPlanService {
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items));
|
||||
}).toList();
|
||||
int blocked = (int) orders.stream().filter(item -> "BLOCKED".equals(item.readiness())).count();
|
||||
int pendingOrders = (int) orders.stream().filter(item -> "READY".equals(item.readiness())).count();
|
||||
int pendingPallets = orders.stream().filter(item -> "READY".equals(item.readiness()))
|
||||
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
|
||||
int pendingOrders = (int) orders.stream().filter(InboundPlanService::requiresContainerConversion).count();
|
||||
int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
|
||||
.map(InboundPlanService::pendingPalletCount)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
|
||||
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
|
||||
@@ -246,7 +263,20 @@ public class InboundPlanService {
|
||||
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
|
||||
String destinationCode = fbaWarehouseCode != null
|
||||
? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode();
|
||||
String readiness = planned ? "PLANNED" : blockers.isEmpty() ? "READY" : "BLOCKED";
|
||||
boolean materialized = planned && row.plannedPalletCount() != null
|
||||
&& row.materializedPalletCount() != null
|
||||
&& row.materializedPalletCount() >= row.plannedPalletCount();
|
||||
if (planned && !materialized && row.materializedPalletCount() != null
|
||||
&& row.materializedPalletCount() > 0) {
|
||||
blockers.add("PLAN_PALLET_BINDING_INCOMPLETE");
|
||||
}
|
||||
if (planned && !materialized && !("PENDING_ASSIGNMENT".equals(row.planStatus())
|
||||
|| "SIGNED".equals(row.planStatus()))) {
|
||||
blockers.add("PLAN_ALREADY_STARTED");
|
||||
}
|
||||
String readiness = planned ? materialized ? "PLANNED"
|
||||
: blockers.isEmpty() ? "RESOURCE_PENDING" : "BLOCKED"
|
||||
: blockers.isEmpty() ? "READY" : "BLOCKED";
|
||||
return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(),
|
||||
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
|
||||
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
|
||||
@@ -276,6 +306,26 @@ public class InboundPlanService {
|
||||
}
|
||||
}
|
||||
|
||||
private int suggestedPalletCount(SourceOrderSnapshot order) {
|
||||
try {
|
||||
return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(order.orderId(),
|
||||
warehouseKey(order), volume(order), order.packageCount(), order.recommendedPalletCount())))
|
||||
.pallets().size();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "订单货量不能计算预览建议托数", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean requiresContainerConversion(ContainerInboundPlanPreviewResponse.Order order) {
|
||||
return "READY".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness());
|
||||
}
|
||||
|
||||
private static Integer pendingPalletCount(ContainerInboundPlanPreviewResponse.Order order) {
|
||||
return "RESOURCE_PENDING".equals(order.readiness())
|
||||
? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
|
||||
: Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
|
||||
}
|
||||
|
||||
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
|
||||
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP";
|
||||
if (isFbaDestination(row)) return "FBA";
|
||||
@@ -552,9 +602,22 @@ public class InboundPlanService {
|
||||
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of()
|
||||
: prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo,
|
||||
PreparedPallet::reservation));
|
||||
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), standardPalletNos.get(pendingIndex),
|
||||
List<String> effectivePalletNos = standardPalletNos.get(pendingIndex);
|
||||
if (prepared != null && !effectivePalletNos.isEmpty()) {
|
||||
effectivePalletNos = prepared.pallets().stream()
|
||||
.sorted(Comparator.comparingInt(PreparedPallet::sequenceNo))
|
||||
.map(value -> value.reservation().palletCode()).toList();
|
||||
}
|
||||
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos,
|
||||
wholePallets.get(pendingIndex), null, null, prepared, reserved));
|
||||
}
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
InboundPlan existing = existingPlans.get(index);
|
||||
PreparedPlan prepared = preparedPlans.get(refs.get(index).orderId());
|
||||
if (existing != null && prepared != null) {
|
||||
materializeExistingPlan(existing, refs.get(index), prepared);
|
||||
}
|
||||
}
|
||||
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
InboundPlan existing = existingPlans.get(index);
|
||||
@@ -568,6 +631,47 @@ public class InboundPlanService {
|
||||
public record PreparedPallet(int sequenceNo, String businessKey,
|
||||
WarehouseResourceGateway.PalletBatchItem reservation) { }
|
||||
|
||||
public PlanIdentity existingPlanIdentity(String customerId, long orderId) {
|
||||
InboundPlan plan = planMapper.findBySourceOrder(customerId, orderId);
|
||||
if (plan == null) return null;
|
||||
return new PlanIdentity(plan.getId(), plan.getPlanNo(), Objects.requireNonNullElse(plan.getPalletCount(), 0));
|
||||
}
|
||||
|
||||
public record PlanIdentity(long planId, String planNo, int palletCount) { }
|
||||
|
||||
private void materializeExistingPlan(InboundPlan plan, InboundPlanOrderMapper.ContainerOrderRef ref,
|
||||
PreparedPlan prepared) {
|
||||
if (!Objects.equals(plan.getId(), prepared.planId()) || !Objects.equals(plan.getPlanNo(), prepared.planNo())
|
||||
|| !Objects.equals(plan.getCustomerId(), prepared.customerId())
|
||||
|| !Objects.equals(ref.orderId(), prepared.orderId())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划与柜级托盘预留快照不一致");
|
||||
}
|
||||
if (!("PENDING_ASSIGNMENT".equals(plan.getStatus()) || "SIGNED".equals(plan.getStatus()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "仅待入库或已签收但未实收的已有计划可由整柜生成补齐托盘");
|
||||
}
|
||||
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId())
|
||||
.eq(InboundPlanPallet::getPlanId, plan.getId())
|
||||
.orderByAsc(InboundPlanPallet::getSequenceNo).last("FOR UPDATE"));
|
||||
if (pallets.size() != prepared.pallets().size() || pallets.size() != plan.getPalletCount()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级托盘预留数量不一致");
|
||||
}
|
||||
Map<Integer, PreparedPallet> preparedBySequence = prepared.pallets().stream().collect(Collectors.toMap(
|
||||
PreparedPallet::sequenceNo, value -> value));
|
||||
for (InboundPlanPallet pallet : pallets) {
|
||||
if (pallet.getWarehousePalletId() != null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划已存在真实托盘,不能重复补齐");
|
||||
}
|
||||
PreparedPallet value = preparedBySequence.get(pallet.getSequenceNo());
|
||||
if (value == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托盘序号不完整");
|
||||
pallet.setSourcePalletNo(value.reservation().palletCode());
|
||||
applyReservedPallet(pallet, value.reservation());
|
||||
palletMapper.updateById(pallet);
|
||||
}
|
||||
plan.setUpdatedAt(LocalDateTime.now());
|
||||
planMapper.updateById(plan);
|
||||
}
|
||||
|
||||
private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) {
|
||||
return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount()));
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) AS plannedInboundCodeCount," +
|
||||
"SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " +
|
||||
"THEN 1 ELSE 0 END) AS pendingPlanCount," +
|
||||
"SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " +
|
||||
"AND COALESCE(p.pallet_count,0)>0 AND " +
|
||||
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
|
||||
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0) " +
|
||||
"THEN 1 ELSE 0 END) AS resourcePendingPlanCount," +
|
||||
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " +
|
||||
"AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," +
|
||||
"CASE WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)=0 " +
|
||||
|
||||
@@ -176,7 +176,7 @@ public class LastlegOrderService {
|
||||
summary.assignmentVersion(), invalidContainer ? false : summary.canChangeReceivingWarehouse(),
|
||||
invalidContainer ? issue : summary.receivingWarehouseLockReason(), summary.totalOrderCount(),
|
||||
summary.inboundCodeCount(), summary.missingInboundCodeCount(), summary.planCount(),
|
||||
summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.plannedArrivalAt(),
|
||||
summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.resourcePendingPlanCount(), summary.plannedArrivalAt(),
|
||||
false, issue);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
|
||||
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
|
||||
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||
ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
|
||||
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
|
||||
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
|
||||
|
||||
@@ -1296,7 +1296,7 @@ components:
|
||||
items: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
ContainerPlanProgressSummary:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
|
||||
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, resourcePendingPlanCount, allPlansReady]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string }
|
||||
containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 }
|
||||
@@ -1312,6 +1312,7 @@ components:
|
||||
planCount: { type: integer, format: int64 }
|
||||
plannedInboundCodeCount: { type: integer, format: int64 }
|
||||
pendingPlanCount: { type: integer, format: int64, description: 尚未生成有效计划的尾程订单数,即未操作数 }
|
||||
resourcePendingPlanCount: { type: integer, format: int64, description: 已有有效计划但尚未完整绑定海外仓真实托盘的订单数 }
|
||||
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at }
|
||||
allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true }
|
||||
blockingReason: { type: string, nullable: true }
|
||||
|
||||
@@ -21,13 +21,13 @@ paths:
|
||||
/api/platform/v1/last-leg/finance-fee-sources/container-customers:
|
||||
get:
|
||||
summary: 查询真实来源柜内客户选项
|
||||
description: 单次按真实来源柜聚合全部客户,供柜详情维护费用时选择,不逐票查询。
|
||||
description: 单次按真实来源柜聚合全部客户,供柜详情维护费用时选择,不逐票查询;货柜不存在或尚无尾程订单时返回空列表。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { in: query, name: containerBookingId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
responses:
|
||||
'200':
|
||||
description: 柜内客户选项
|
||||
description: 柜内客户选项;无数据时data为空数组
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ContainerCustomerListResponse' }
|
||||
|
||||
+10
@@ -80,6 +80,16 @@ class OverseasFeeBusinessGuardTest {
|
||||
assertThat(jdbc.sql()).singleElement().asString().contains("GROUP BY customer_id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyWhenContainerHasNoLastlegOrders() {
|
||||
StubJdbc jdbc = new StubJdbc();
|
||||
|
||||
var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
assertThat(jdbc.sql()).singleElement().asString().contains("source_container_booking_id");
|
||||
}
|
||||
|
||||
static SaveRequest containerFee(String customerId, Long bookingId, String containerNo) {
|
||||
return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER",
|
||||
bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L,
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ class InboundPlanControllerTest {
|
||||
void platformManualPlanPassesVersionedIdempotentCommand() {
|
||||
ManualInboundPlanRequest request = manualRequest();
|
||||
controller.manual(platform(), "manual-key", request);
|
||||
verify(service).convertManual(request, "manual-key", "7");
|
||||
verify(containerConversion).convertManualAndAutoMaterialize(request, "manual-key", "7");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,7 +76,7 @@ class InboundPlanControllerTest {
|
||||
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest()))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(),
|
||||
verify(containerConversion, never()).convertManualAndAutoMaterialize(org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
|
||||
+9
-9
@@ -33,11 +33,11 @@ class HttpUnloadPhotoFileGatewayTest {
|
||||
|
||||
@Test
|
||||
void forgedImageBusinessFailureMapsToBadRequestInsteadOfInternalError() {
|
||||
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8"))
|
||||
server.expect(requestTo("http://file-service/internal/file/v1/business-files/0/OVERSEAS_UNLOAD_PHOTO/8"))
|
||||
.andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThatThrownBy(() -> gateway.upload("100", 8L, 7L,
|
||||
assertThatThrownBy(() -> gateway.upload("0", 8L, 7L,
|
||||
new MockMultipartFile("file", "forged.png", "image/png", "<script>".getBytes())))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
|
||||
@@ -46,11 +46,11 @@ class HttpUnloadPhotoFileGatewayTest {
|
||||
|
||||
@Test
|
||||
void mismatchedPhotoMetadataMapsToBadRequest() {
|
||||
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9"))
|
||||
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"fileId\":9,\"tenantId\":101,\"businessType\":\"OVERSEAS_UNLOAD_PHOTO\",\"businessId\":\"8\"}}",
|
||||
server.expect(requestTo("http://file-service/internal/file/v1/business-files/0/OVERSEAS_UNLOAD_PHOTO/8/9"))
|
||||
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"fileId\":9,\"tenantId\":1,\"businessType\":\"OVERSEAS_UNLOAD_PHOTO\",\"businessId\":\"8\"}}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThatThrownBy(() -> gateway.validate("100", 8L, 9L))
|
||||
assertThatThrownBy(() -> gateway.validate("0", 8L, 9L))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
|
||||
server.verify();
|
||||
@@ -59,12 +59,12 @@ class HttpUnloadPhotoFileGatewayTest {
|
||||
@Test
|
||||
void downloadUsesBusinessOwnershipPathAndPreservesBlobMetadata() {
|
||||
server.expect(requestTo(
|
||||
"http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
|
||||
"http://file-service/internal/file/v1/business-files/0/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
|
||||
.andExpect(header("X-Internal-Service-Token", "internal-token"))
|
||||
.andRespond(withSuccess("photo-bytes".getBytes(), MediaType.IMAGE_JPEG)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=door.jpg"));
|
||||
|
||||
UnloadPhotoFileGateway.DownloadedFile result = gateway.download("100", 8L, 9L);
|
||||
UnloadPhotoFileGateway.DownloadedFile result = gateway.download("0", 8L, 9L);
|
||||
|
||||
assertThat(result.content()).containsExactly("photo-bytes".getBytes());
|
||||
assertThat(result.fileName()).isEqualTo("door.jpg");
|
||||
@@ -75,10 +75,10 @@ class HttpUnloadPhotoFileGatewayTest {
|
||||
@Test
|
||||
void missingOwnedPhotoMapsToNotFound() {
|
||||
server.expect(requestTo(
|
||||
"http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
|
||||
"http://file-service/internal/file/v1/business-files/0/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
|
||||
.andRespond(withStatus(HttpStatus.NOT_FOUND));
|
||||
|
||||
assertThatThrownBy(() -> gateway.download("100", 8L, 9L))
|
||||
assertThatThrownBy(() -> gateway.download("0", 8L, 9L))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
|
||||
server.verify();
|
||||
|
||||
+19
-1
@@ -85,6 +85,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
service.initializeObjectMapper();
|
||||
org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1);
|
||||
org.mockito.Mockito.lenient().when(pallets.selectCount(any())).thenReturn(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -256,6 +257,23 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
verify(plans).updateById(unassigned);
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepareRejectsManualPlanBeforeRealPalletsAreMaterialized() {
|
||||
InboundPlan plan = plan("TSTU0195472");
|
||||
InboundPlanLine planLine = line("FBA");
|
||||
LastlegOrder order = containerOrder(1L, "100", "TSTU0195472", "BOOKING-472", "IN-472");
|
||||
when(plans.selectList(any())).thenReturn(List.of(plan));
|
||||
when(lines.selectList(any())).thenReturn(List.of(planLine));
|
||||
when(orders.selectList(any())).thenReturn(List.of(order));
|
||||
when(pallets.selectCount(any())).thenReturn(0L);
|
||||
|
||||
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472",
|
||||
new ReceivingContainerRequests.Action("missing-real-pallets")))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("尚未完整绑定真实托盘");
|
||||
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidatesHideContainersReservedByAnotherOperator() {
|
||||
InboundPlan available = plan("CONT-A");
|
||||
@@ -863,7 +881,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
private static InboundPlan plan(String containerNo) {
|
||||
InboundPlan value = new InboundPlan();
|
||||
value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo);
|
||||
value.setStatus("SIGNED"); value.setOverseasWarehouseId(100L);
|
||||
value.setStatus("SIGNED"); value.setOverseasWarehouseId(100L); value.setPalletCount(1);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
+137
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
@@ -18,6 +20,7 @@ import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@@ -28,6 +31,7 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ContainerInboundPlanConversionCoordinatorTest {
|
||||
@@ -57,6 +61,43 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
.thenReturn(new SimpleTransactionStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalManualPlanAutomaticallyMaterializesSingleOrderContainer() {
|
||||
ManualInboundPlanRequest request = manualRequest();
|
||||
InboundPlanResponse logical = plan("IP-MANUAL", List.of());
|
||||
InboundPlanResponse physical = plan("IP-MANUAL", List.of(new InboundPlanResponse.Pallet(
|
||||
31L, "MSCU6639870-101-001", null, "ACY2", "Amazon ACY2", BigDecimal.ONE,
|
||||
BigDecimal.ONE, 1, 51L, 61L, 71L, "WOOD", "LOCKED", LocalDateTime.now(), null, 0)));
|
||||
ContainerInboundPlanConversionCoordinator spy = org.mockito.Mockito.spy(coordinator);
|
||||
when(plans.convertManual(request, "manual-key", "7")).thenReturn(logical);
|
||||
when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID);
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder())));
|
||||
doReturn(List.of(physical)).when(spy).confirm(any(ConvertContainerInboundPlanRequest.class), any(String.class));
|
||||
|
||||
assertEquals(physical, spy.convertManualAndAutoMaterialize(request, "manual-key", "7"));
|
||||
|
||||
verify(spy).confirm(eq(new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH)),
|
||||
any(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void earlierManualPlanWaitsUntilEveryOrderInContainerHasAPlan() {
|
||||
ManualInboundPlanRequest request = manualRequest();
|
||||
InboundPlanResponse logical = plan("IP-MANUAL", List.of());
|
||||
var ready = new ContainerInboundPlanPreviewResponse.Order(
|
||||
"200", "Customer 200", "12", "LM-12", "RK-2", "FIRST_LEG", "FBA",
|
||||
null, "ONT8", "Amazon ONT8", "ONT8", 10, BigDecimal.ONE, 1,
|
||||
null, null, null, 0, "READY", List.of());
|
||||
when(plans.convertManual(request, "manual-key", "7")).thenReturn(logical);
|
||||
when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID);
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder(), ready)));
|
||||
|
||||
assertEquals(logical, coordinator.convertManualAndAutoMaterialize(request, "manual-key", "7"));
|
||||
|
||||
verify(commands, never()).findByBooking(any(String.class));
|
||||
verify(warehouse, never()).ensurePalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherIdempotencyKeyForCompletedCabinetReplaysWithoutCreatingAnotherBatch() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
@@ -290,6 +331,72 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
.map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void standardFbaPreviewMaySupplementButNeverReduceHeadLegPalletCodes() {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
var order = new ContainerInboundPlanPreviewResponse.Order(
|
||||
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
|
||||
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
|
||||
null, null, null, 0, "READY", List.of());
|
||||
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 0, 3, 0, true,
|
||||
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
|
||||
1, 1, 3, 0, 3, 0, List.of(order))));
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
|
||||
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2");
|
||||
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
|
||||
"MSCU6639870-101-001", "MSCU6639870-101-002"));
|
||||
|
||||
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
|
||||
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
|
||||
ReflectionTestUtils.invokeMethod(coordinator, "hash",
|
||||
List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash())));
|
||||
ContainerInboundPlanConversionCoordinator.Preparation preparation;
|
||||
try {
|
||||
preparation = objectMapper.readValue(command.getPreparationJson(),
|
||||
ContainerInboundPlanConversionCoordinator.Preparation.class);
|
||||
} catch (Exception exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
|
||||
assertEquals(List.of("MSCU6639870-101-001", "MSCU6639870-101-002", "MSCU6639870-101-003"),
|
||||
preparation.items().stream().map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingManualPlanIsPreparedForWarehouseMaterialization() {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
var order = new ContainerInboundPlanPreviewResponse.Order(
|
||||
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
|
||||
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
|
||||
3, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
|
||||
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 3, 3, 3, true,
|
||||
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
|
||||
1, 1, 3, 3, 3, 3, List.of(order))));
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
|
||||
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
|
||||
"MSCU6639870-101-001", "MSCU6639870-101-002"));
|
||||
when(plans.existingPlanIdentity("100", 11L))
|
||||
.thenReturn(new InboundPlanService.PlanIdentity(21L, "IP-MANUAL", 3));
|
||||
|
||||
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
|
||||
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
|
||||
ReflectionTestUtils.invokeMethod(coordinator, "hash",
|
||||
List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash())));
|
||||
ContainerInboundPlanConversionCoordinator.Preparation preparation;
|
||||
try {
|
||||
preparation = objectMapper.readValue(command.getPreparationJson(),
|
||||
ContainerInboundPlanConversionCoordinator.Preparation.class);
|
||||
} catch (Exception exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
|
||||
assertEquals(21L, preparation.items().get(0).planId());
|
||||
assertEquals("IP-MANUAL", preparation.items().get(0).planNo());
|
||||
verify(numbers, never()).next(any(String.class), any(String.class), any(Long.class));
|
||||
}
|
||||
|
||||
private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId,
|
||||
String inboundCode, String destinationCode,
|
||||
String fbaWarehouseCode) {
|
||||
@@ -300,6 +407,36 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
fbaWarehouseCode, 10, BigDecimal.ONE, 1, null, null, null, 0, "READY", List.of());
|
||||
}
|
||||
|
||||
private ManualInboundPlanRequest manualRequest() {
|
||||
return new ManualInboundPlanRequest("100", 11L, 0, LocalDate.of(2026, 9, 10),
|
||||
1, "WOOD", false, false, false, "自动生成");
|
||||
}
|
||||
|
||||
private ContainerInboundPlanPreviewResponse.Order resourcePendingOrder() {
|
||||
return new ContainerInboundPlanPreviewResponse.Order(
|
||||
"100", "Customer 100", "11", "LM-11", "RK-1", "FIRST_LEG", "FBA",
|
||||
null, "ACY2", "Amazon ACY2", "ACY2", 10, BigDecimal.ONE, 1,
|
||||
1, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
|
||||
}
|
||||
|
||||
private ContainerInboundPlanPreviewResponse manualPreview(
|
||||
List<ContainerInboundPlanPreviewResponse.Order> orders) {
|
||||
int pending = (int) orders.stream().filter(order -> "RESOURCE_PENDING".equals(order.readiness())
|
||||
|| "READY".equals(order.readiness())).count();
|
||||
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||
"Los Angeles", 7L, SNAPSHOT_HASH, orders.size(), orders.size(), 0, pending, pending,
|
||||
0, orders.size(), 1, true,
|
||||
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
|
||||
orders.size(), pending, pending, 0, orders.size(), 1, orders)));
|
||||
}
|
||||
|
||||
private InboundPlanResponse plan(String planNo, List<InboundPlanResponse.Pallet> pallets) {
|
||||
return new InboundPlanResponse(21L, planNo, "100", 11L, "MSCU6639870", "US-LAX-01",
|
||||
"Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.ONE, 10, 0,
|
||||
List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false,
|
||||
false, false, "MANUAL", "自动生成");
|
||||
}
|
||||
|
||||
private ConvertContainerInboundPlanRequest request() {
|
||||
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
|
||||
}
|
||||
|
||||
+26
-2
@@ -74,7 +74,7 @@ class ContainerInboundPlanPreviewTest {
|
||||
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
|
||||
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
|
||||
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
|
||||
null, null, null)));
|
||||
null, null, null, null)));
|
||||
|
||||
var result = service.previewContainer("BOOKING-NULL");
|
||||
|
||||
@@ -103,9 +103,33 @@ class ContainerInboundPlanPreviewTest {
|
||||
.map(ContainerInboundPlanPreviewResponse.Group::destinationType).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedManualPlanWithoutWarehousePalletsRemainsResourcePending() {
|
||||
when(orderMapper.containerPreviewOrders("BOOKING-SIGNED")).thenReturn(List.of(
|
||||
row(1L, "LM-SIGNED", "FIRST_LEG", "FBA", "ACY2", "Amazon ACY2", 3,
|
||||
"IP-SIGNED", 3, 1, "SIGNED", 0)
|
||||
));
|
||||
|
||||
var result = service.previewContainer("BOOKING-SIGNED");
|
||||
|
||||
assertEquals(true, result.ready());
|
||||
assertEquals(1, result.pendingOrderCount());
|
||||
assertEquals(3, result.pendingPalletCount());
|
||||
assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness());
|
||||
}
|
||||
|
||||
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
|
||||
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
|
||||
int recommendedPallets, String planNo, Integer plannedPallets, int version) {
|
||||
return row(id, orderNo, orderType, destinationType, destinationCode, destinationName,
|
||||
recommendedPallets, planNo, plannedPallets, version,
|
||||
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets);
|
||||
}
|
||||
|
||||
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
|
||||
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
|
||||
int recommendedPallets, String planNo, Integer plannedPallets, int version, String planStatus,
|
||||
Integer materializedPallets) {
|
||||
return new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", id, orderNo, "FO-" + id,
|
||||
"测试客户", orderType, destinationType, "IN-" + id, "PENDING_ASSIGNMENT", planNo,
|
||||
"MSCU6639870", recommendedPallets, 10, new BigDecimal("1.0000"),
|
||||
@@ -113,6 +137,6 @@ class ContainerInboundPlanPreviewTest {
|
||||
"FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id,
|
||||
"FBA".equals(destinationType) ? null : destinationCode, destinationName,
|
||||
"FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo,
|
||||
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets);
|
||||
planStatus, plannedPallets, materializedPallets);
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -164,6 +164,54 @@ class InboundPlanServiceTest {
|
||||
verify(orderMapper).lockContainerWarehouseFacts("100", 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualPlanMayExceedButCannotUndercutPreviewPalletCount() {
|
||||
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
|
||||
"PENDING_ASSIGNMENT", null, new BigDecimal("5.8000"), 58, 2,
|
||||
"193000000000200003", "US-LAX-01", "洛杉矶仓", "ACY2",
|
||||
null, null, "Amazon ACY2", 0, 0, false, 0);
|
||||
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
|
||||
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
|
||||
2, "WOOD", false, false, false, null), "manual-below-preview", "900"));
|
||||
|
||||
assertEquals(400, error.getStatusCode().value());
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerConversionBindsRealWarehousePalletsBackToSignedManualPlanBeforeReceiving() {
|
||||
InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL");
|
||||
plan.setCreationMode("MANUAL");
|
||||
plan.setPalletCount(2);
|
||||
plan.setStatus("SIGNED");
|
||||
InboundPlanPallet first = new InboundPlanPallet();
|
||||
first.setId(301L); first.setCustomerId("100"); first.setPlanId(plan.getId()); first.setSequenceNo(1);
|
||||
InboundPlanPallet second = new InboundPlanPallet();
|
||||
second.setId(302L); second.setCustomerId("100"); second.setPlanId(plan.getId()); second.setSequenceNo(2);
|
||||
when(palletMapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
var firstReservation = new WarehouseResourceGateway.PalletBatchItem(100L, "CIP:1:1", "IP-MANUAL",
|
||||
1, 401L, "MSCU6639870-101-001", 501L, 601L, "RESERVED");
|
||||
var secondReservation = new WarehouseResourceGateway.PalletBatchItem(100L, "CIP:1:2", "IP-MANUAL",
|
||||
2, 402L, "MSCU6639870-101-002", 502L, 602L, "RESERVED");
|
||||
var prepared = new InboundPlanService.PreparedPlan(plan.getId(), plan.getPlanNo(), "100", 2L, List.of(
|
||||
new InboundPlanService.PreparedPallet(1, "CIP:1:1", firstReservation),
|
||||
new InboundPlanService.PreparedPallet(2, "CIP:1:2", secondReservation)));
|
||||
|
||||
ReflectionTestUtils.invokeMethod(service, "materializeExistingPlan", plan,
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "INBOUND_PLANNED", "MSCU6639870", "FIRST_LEG"),
|
||||
prepared);
|
||||
|
||||
assertEquals("MSCU6639870-101-001", first.getPalletNo());
|
||||
assertEquals("MSCU6639870-101-001", first.getSourcePalletNo());
|
||||
assertEquals(401L, first.getWarehousePalletId());
|
||||
assertEquals("RESERVED", first.getAssignmentStatus());
|
||||
assertEquals(402L, second.getWarehousePalletId());
|
||||
verify(palletMapper, times(2)).updateById(any(InboundPlanPallet.class));
|
||||
verify(planMapper).updateById(plan);
|
||||
}
|
||||
|
||||
@Test
|
||||
void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() {
|
||||
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
|
||||
|
||||
+2
-1
@@ -27,7 +27,7 @@ class ContainerPlanProgressContractTest {
|
||||
assertEquals(List.of("sourceContainerBookingId", "containerNo", "overseasWarehouseId",
|
||||
"overseasWarehouseCode", "overseasWarehouseName", "assignmentVersion",
|
||||
"canChangeReceivingWarehouse", "receivingWarehouseLockReason", "totalOrderCount", "inboundCodeCount",
|
||||
"missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount",
|
||||
"missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount", "resourcePendingPlanCount",
|
||||
"plannedArrivalAt", "allPlansReady", "blockingReason"),
|
||||
Arrays.stream(ContainerPlanProgressSummary.class.getRecordComponents())
|
||||
.map(value -> value.getName()).toList());
|
||||
@@ -40,6 +40,7 @@ class ContainerPlanProgressContractTest {
|
||||
LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class);
|
||||
String sql = String.join("", rows.value()).toLowerCase();
|
||||
assertTrue(sql.contains("left join ll_inbound_plan"));
|
||||
assertTrue(sql.contains("ll_inbound_plan_pallet"));
|
||||
assertTrue(sql.contains("o.estimated_warehouse_at"));
|
||||
assertTrue(sql.contains("group by o.source_container_booking_id"));
|
||||
assertFalse(sql.contains("having "));
|
||||
|
||||
+4
-4
@@ -97,7 +97,7 @@ class LastlegOrderServiceTest {
|
||||
}
|
||||
var updated = new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "900",
|
||||
"US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 2, 2, 0,
|
||||
null, true, null);
|
||||
0, null, true, null);
|
||||
when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second));
|
||||
when(warehouseResourceGateway.requireActiveSharedWarehouse(900L)).thenReturn(
|
||||
new WarehouseResourceGateway.WarehouseReference(900L, "US-NYC-01", "纽约仓", "ACTIVE"));
|
||||
@@ -382,7 +382,7 @@ class LastlegOrderServiceTest {
|
||||
var healthy = summary(false, 3, "存在3票尚未生成有效入库计划");
|
||||
var missingWarehouse = new ContainerPlanProgressSummary("BOOKING-2", "MSCU6639870",
|
||||
null, null, null, 0, true, null, 2, 2, 0, 0, 0, 2,
|
||||
LocalDateTime.of(2026, 10, 4, 9, 0), false, "存在2票尚未生成有效入库计划");
|
||||
0, LocalDateTime.of(2026, 10, 4, 9, 0), false, "存在2票尚未生成有效入库计划");
|
||||
when(orders.countContainerProgress(query)).thenReturn(2L);
|
||||
when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(healthy, missingWarehouse));
|
||||
|
||||
@@ -402,7 +402,7 @@ class LastlegOrderServiceTest {
|
||||
var locked = new ContainerPlanProgressSummary("BOOKING-2", "TSTU0000061",
|
||||
"193000000000200003", "US-LAX-01", "洛杉矶仓", 4, false,
|
||||
"海外PDA已建立接柜会话,接柜仓已永久锁定", 1, 1, 0, 1, 1, 0,
|
||||
LocalDateTime.of(2026, 10, 4, 9, 0), true, null);
|
||||
0, LocalDateTime.of(2026, 10, 4, 9, 0), true, null);
|
||||
when(orders.countContainerProgress(query)).thenReturn(2L);
|
||||
when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(ready, locked));
|
||||
|
||||
@@ -419,7 +419,7 @@ class LastlegOrderServiceTest {
|
||||
private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) {
|
||||
return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "193000000000200003",
|
||||
"US-LAX-01", "洛杉矶仓", 0, true, null, 15, 15, 0,
|
||||
15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
|
||||
15 - pending, 15 - pending, pending, 0, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user