This commit is contained in:
2026-09-07 15:07:34 +08:00
parent 693e10188c
commit 1b47158770
37 changed files with 688 additions and 144 deletions
+4 -2
View File
@@ -18,7 +18,7 @@
| 任务关键词 | 首选服务 | 先看入口 |
|---|---|---|
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
@@ -26,7 +26,8 @@
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 |
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。首张称重确认分配整票块,测量提交只复用;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
| 入库扣货、解除、履约门禁与凭证 | `firstleg/`、`file/` | Firstleg `InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`;File 业务类型 `INBOUND_HOLD_EVIDENCE`。扣货阻断物理履约写入,已发生实物作业后禁止手工切换,命令按入库版本和幂等键执行 |
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
@@ -36,6 +37,7 @@
| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 客户资金账户、充值、授信、冻结与结算 | `finance/` | `FinanceWalletService.java`、`ClientFinanceWalletController.java`、`PlatformFinanceWalletController.java`、`InternalFinanceWalletController.java`、`finance-wallet-v1.yaml`;Finance 持有 CNY 主账和不可变流水,业务服务只能经内部命令或跨区 Wallet 事件冻结、结算、释放 |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
@@ -1776,3 +1776,69 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id),
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
-- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_order' AND column_name='overseas_warehouse_code'),
'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照''',
'ALTER TABLE ll_order ADD COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_order' AND column_name='receiving_warehouse_version'),
'ALTER TABLE ll_order MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本''',
'ALTER TABLE ll_order ADD COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本'' AFTER overseas_warehouse_name'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_order
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外接柜仓ID;新建尾程订单默认洛杉矶仓',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照';
ALTER TABLE ll_inbound_plan
MODIFY COLUMN overseas_warehouse_id BIGINT NULL COMMENT '从尾程订单继承的平台预分配海外接柜仓ID';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_code'),
'ALTER TABLE ll_receiving_container_session MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照''',
'ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_receiving_container_session
MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照';
CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation (
id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID',
idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要',
result_version INT NOT NULL COMMENT '修改成功后的柜级分配版本',
operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称',
remark VARCHAR(500) NULL COMMENT '接柜仓修改备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_warehouse_idempotency(idempotency_key),
KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at)
) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录';
DROP PROCEDURE IF EXISTS assert_receiving_warehouse_assignment_ready;
DELIMITER $$
CREATE PROCEDURE assert_receiving_warehouse_assignment_ready()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
AND table_name='ll_order' AND column_name IN ('overseas_warehouse_code','receiving_warehouse_version')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单海外接柜仓字段未完整部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_code') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA接柜会话仓库编码字段未部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg'
AND table_name='ll_receiving_warehouse_operation') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='柜级海外接柜仓修改幂等审计表未部署';
END IF;
END$$
DELIMITER ;
CALL assert_receiving_warehouse_assignment_ready();
DROP PROCEDURE assert_receiving_warehouse_assignment_ready;
SELECT 'PASS' AS migration_status, '海外接柜仓预分配与柜级修改结构已就绪' AS message;
@@ -102,6 +102,20 @@ public class LastlegOrderController {
return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId));
}
@PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "修改整柜海外接柜仓",
description = "仅在海外PDA建立接柜会话前允许修改;按 expectedVersion 乐观锁原子同步柜内订单与入库计划。")
public ApiResponse<ContainerPlanProgressSummary> changeReceivingWarehouse(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String sourceContainerBookingId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody LastlegOrderRequests.ChangeReceivingWarehouse request) {
platformScope(user, null);
return ApiResponse.success(orderService.changeReceivingWarehouse(sourceContainerBookingId,
idempotencyKey, request, operatorName(user)));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
@@ -36,7 +36,13 @@ public interface DispatchCandidateMapper {
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
@@ -94,7 +100,13 @@ public interface DispatchCandidateMapper {
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
@@ -220,10 +220,16 @@ public class DispatchService {
if (selected.size() != palletIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
}
boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.anyMatch(value -> value == null || value.isBlank());
if (missingWarehouse) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"所选托盘中存在尚未关联海外仓的托盘");
}
Set<String> warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet());
if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse");
.map(String::trim).collect(Collectors.toSet());
if (warehouseIds.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘");
}
String draftToken = trimToNull(request.draftToken());
for (DispatchPalletCandidate candidate : selected) {
@@ -6,6 +6,12 @@ import java.time.LocalDateTime;
public record ContainerPlanProgressSummary(
String sourceContainerBookingId,
String containerNo,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
int assignmentVersion,
boolean canChangeReceivingWarehouse,
String receivingWarehouseLockReason,
long totalOrderCount,
long inboundCodeCount,
long missingInboundCodeCount,
@@ -16,4 +22,14 @@ public record ContainerPlanProgressSummary(
boolean allPlansReady,
String blockingReason
) {
public ContainerPlanProgressSummary(String sourceContainerBookingId, String containerNo,
long totalOrderCount, long inboundCodeCount,
long missingInboundCodeCount, long planCount,
long plannedInboundCodeCount, long pendingPlanCount,
LocalDateTime plannedArrivalAt, boolean allPlansReady,
String blockingReason) {
this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null,
totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount,
plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason);
}
}
@@ -54,6 +54,12 @@ public final class LastlegOrderRequests {
}
}
public record ChangeReceivingWarehouse(
@NotNull Long overseasWarehouseId,
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String remark
) { }
public record TransferDestination(
@NotEmpty @Size(max = 200) List<Long> orderIds,
@NotBlank @Size(max = 128) String warehouseId,
@@ -22,6 +22,7 @@ public record LastlegOrderSummary(
String customerId,
String customerName,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String transferWarehouseId,
String transferWarehouseCode,
@@ -28,7 +28,9 @@ public class LastlegOrder {
private String sourceWarehouseId;
private String sourceWarehouseName;
private String overseasWarehouseId;
private String overseasWarehouseCode;
private String overseasWarehouseName;
private Integer receivingWarehouseVersion;
private String fbaWarehouseCode;
private String containerNo;
private String transferWarehouseId;
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_warehouse_operation")
public class ReceivingWarehouseOperation {
@TableId(type = IdType.INPUT) private Long id;
private String sourceContainerBookingId;
private String idempotencyKey;
private String requestHash;
private Integer resultVersion;
private String operatorName;
private String remark;
private LocalDateTime createdAt;
}
@@ -12,6 +12,8 @@ public record InboundPlanListResponse(
String inboundCode,
String lxCode,
String orderType,
String overseasWarehouseCode,
String overseasWarehouseName,
String warehouseId,
String warehouseCode,
String warehouseName,
@@ -29,18 +31,4 @@ public record InboundPlanListResponse(
String dispatchStatus,
LocalDateTime createdAt,
Integer version
) {
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
Long orderId, String inboundCode, String orderType,
String warehouseId, String warehouseCode, String warehouseName,
String fbaWarehouseCode, String customerName, Integer packageCount,
BigDecimal volumeCbm, Integer palletCount, String status,
String appointmentNo, String appointmentStatus,
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
String dispatchStatus, LocalDateTime createdAt) {
this(lineId, planId, planNo, customerId, orderId, inboundCode, null, orderType, warehouseId,
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt, 0);
}
}
) { }
@@ -10,6 +10,8 @@ public record InboundPlanResponse(
String customerId,
Long sourceOrderId,
String containerNo,
String overseasWarehouseCode,
String overseasWarehouseName,
String status,
int lineCount,
int palletCount,
@@ -21,22 +23,6 @@ public record InboundPlanResponse(
List<Allocation> allocations,
int version
) {
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines, pallets, allocations, 0);
}
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations, int version) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version);
}
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
@@ -14,6 +14,8 @@ import java.util.List;
public interface InboundPlanOrderMapper {
record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo,
String orderType) { }
record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName) { }
@Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," +
"order_type AS orderType " +
@@ -26,7 +28,8 @@ public interface InboundPlanOrderMapper {
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
"COALESCE(o.pallet_count,0) AS recommendedPalletCount," +
"COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," +
"o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," +
"NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
@@ -36,12 +39,19 @@ public interface InboundPlanOrderMapper {
"0 AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," +
"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 " +
"ORDER BY o.id FOR UPDATE")
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT overseas_warehouse_id AS overseasWarehouseId," +
"overseas_warehouse_code AS overseasWarehouseCode,overseas_warehouse_name AS overseasWarehouseName " +
"FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId}")
@@ -25,7 +25,7 @@ public interface InboundPlanQueryMapper {
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.lxCode != null and q.lxCode != \"\"'> AND o.lx_code LIKE CONCAT('%',#{q.lxCode},'%')</if>" +
"<if test='q.status != null and q.status != \"\"'> AND p.status=#{q.status}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND l.destination_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND o.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.warehouse != null and q.warehouse != \"\"'> AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))</if>" +
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
@@ -38,6 +38,7 @@ public interface InboundPlanQueryMapper {
@Select("<script>SELECT l.id lineId,p.id planId,p.plan_no planNo,p.customer_id customerId," +
"l.order_id orderId,l.inbound_code inboundCode,o.lx_code lxCode,o.order_type orderType," +
"o.overseas_warehouse_code overseasWarehouseCode,o.overseas_warehouse_name overseasWarehouseName," +
"l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," +
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
"o.customer_name customerName,l.required_package_count packageCount," +
@@ -12,30 +12,13 @@ public record SourceOrderSnapshot(
BigDecimal volumeCbm,
int packageCount,
int recommendedPalletCount,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String fbaWarehouseCode,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int destinationCount,
int destinationMismatchCount
) {
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, 0, fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, 0, null, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
}
) { }
@@ -6,22 +6,46 @@ import java.util.List;
public final class ReceivingContainerResponses {
private ReceivingContainerResponses() { }
public record Candidate(String sourceContainerBookingId, String containerNo, String status,
public record Candidate(String sourceContainerBookingId, String containerNo,
String overseasWarehouseCode, String overseasWarehouseName, String status,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages,
boolean operable, boolean allPlansReady, String blockReason,
LocalDateTime plannedArrivalAt, boolean currentAccount) { }
LocalDateTime plannedArrivalAt, boolean currentAccount) {
public Candidate(String sourceContainerBookingId, String containerNo, String status,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
List<String> destinationCodes, int plannedPalletCount, int totalPackages,
int receivedPackages, boolean operable, boolean allPlansReady,
String blockReason, LocalDateTime plannedArrivalAt, boolean currentAccount) {
this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount,
inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount,
pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages,
receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount);
}
}
public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { }
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName,
String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) { }
List<Inbound> inbounds) {
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version, List<SignedPhoto> photos,
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
LocalDateTime completedAt, int totalPackages, int receivedPackages,
int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
receivedPackages, pendingPackages, inbounds);
}
}
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
@@ -20,6 +20,7 @@ public class ReceivingContainerSession {
private String containerFactHash;
private String containerNo;
private Long overseasWarehouseId;
private String overseasWarehouseCode;
private String overseasWarehouseName;
private String operatorKey;
private String operatorName;
@@ -126,7 +126,7 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse");
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
requireReceivingAvailable(customerId, containerNo);
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
@@ -158,7 +158,7 @@ public class ReceivingContainerService {
}
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
requireReceivingAvailable(customerId, containerNo);
String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
@@ -167,7 +167,6 @@ public class ReceivingContainerService {
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
}
@@ -176,13 +175,13 @@ public class ReceivingContainerService {
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
LocalDateTime now = LocalDateTime.now();
ReceivingContainerSession session = new ReceivingContainerSession();
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
session.setSourceContainerBookingId(readiness.sourceContainerBookingId());
session.setContainerFactHash(readiness.factHash());
session.setOverseasWarehouseId(overseasWarehouseId);
session.setOverseasWarehouseCode(receivingWarehouse.warehouseCode());
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
@@ -412,7 +411,7 @@ public class ReceivingContainerService {
ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts,
overseasWarehouseId);
if (value == null || !value.operable()) {
throw conflict(value == null ? "Container is assigned to another overseas warehouse" : value.blockReason());
throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason());
}
return value;
}
@@ -444,8 +443,13 @@ public class ReceivingContainerService {
try { assignedWarehouses.add(Long.valueOf(value)); }
catch (NumberFormatException ignored) { assignedWarehouses.add(Long.MIN_VALUE); }
});
if (assignedWarehouses.size() > 1) throw conflict("Container plans have inconsistent receiving warehouse assignments");
if (!assignedWarehouses.isEmpty() && !assignedWarehouses.contains(overseasWarehouseId)) return null;
if (assignedWarehouses.size() > 1) throw conflict("柜内接柜仓分配不一致");
if (assignedWarehouses.size() != 1 || !assignedWarehouses.contains(overseasWarehouseId)) return null;
Set<String> warehouseCodes = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseCode)
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
Set<String> warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName)
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
if (warehouseCodes.size() != 1 || warehouseNames.size() != 1) throw conflict("柜内接柜仓业务快照不一致");
Set<String> customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet());
String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null);
int totalOrders = orderFacts.size();
@@ -488,7 +492,8 @@ public class ReceivingContainerService {
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(),
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
return new ContainerReadiness(sourceContainerBookingId, containerNo, totalOrders, inboundCodes,
return new ContainerReadiness(sourceContainerBookingId, containerNo, warehouseCodes.iterator().next(),
warehouseNames.iterator().next(), totalOrders, inboundCodes,
totalOrders - inboundCodes, planCount, plannedOrders, pending, allPlansReady, allPlansCompleted, shippingReady, operable,
blockReason, arrival, factHash, totals);
}
@@ -498,6 +503,7 @@ public class ReceivingContainerService {
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
Totals totals = value.totals();
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
value.overseasWarehouseCode(), value.overseasWarehouseName(),
active ? current.getStatus() : "WAIT_SIGN", value.totalOrderCount(), value.inboundCodeCount(),
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
@@ -663,7 +669,7 @@ public class ReceivingContainerService {
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
}
@@ -864,6 +870,7 @@ public class ReceivingContainerService {
String containerNo) {
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
value.setOverseasWarehouseId(warehouse.warehouseId());
value.setOverseasWarehouseCode(warehouse.warehouseCode());
value.setOverseasWarehouseName(warehouse.warehouseName());
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
return value;
@@ -881,7 +888,7 @@ public class ReceivingContainerService {
if (assigned.size() > 1) {
throw conflict("Container plans have inconsistent receiving warehouse assignments");
}
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
return assigned.size() == 1 && assigned.contains(overseasWarehouseId);
}
private void requireReceivingAvailable(String customerId, String containerNo) {
@@ -890,30 +897,6 @@ public class ReceivingContainerService {
}
}
private void claimReceivingWarehouse(String customerId, List<InboundPlan> containerPlans,
WarehouseResourceGateway.WarehouseReference warehouse) {
long overseasWarehouseId = warehouse.warehouseId();
String warehouseId = String.valueOf(overseasWarehouseId);
for (InboundPlan plan : containerPlans) {
int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId);
InboundPlan actual = plans.selectById(plan.getId());
if ((claimed == 0 && actual == null) || actual == null
|| !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Container was claimed by another overseas warehouse");
}
plan.setOverseasWarehouseId(overseasWarehouseId);
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId,
warehouse.warehouseName());
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) {
throw conflict("Inbound order was claimed by another overseas warehouse");
}
}
}
}
private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) {
if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Current container belongs to another overseas warehouse");
@@ -990,6 +973,7 @@ public class ReceivingContainerService {
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { }
private record ContainerReadiness(String sourceContainerBookingId, String containerNo,
String overseasWarehouseCode, String overseasWarehouseName,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason,
@@ -126,6 +126,15 @@ public class InboundPlanService {
plan.setCustomerId(customerId);
plan.setSourceOrderId(selectedOrderId);
plan.setContainerNo(containerNo);
String receivingWarehouseId = trimToNull(orders.get(0).overseasWarehouseId());
if (receivingWarehouseId == null || orders.stream().anyMatch(order ->
!Objects.equals(receivingWarehouseId, trimToNull(order.overseasWarehouseId())))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配或存在不一致");
}
try { plan.setOverseasWarehouseId(Long.parseLong(receivingWarehouseId)); }
catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception);
}
plan.setStatus("PENDING_ASSIGNMENT");
plan.setLineCount(orders.size());
plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm)
@@ -495,6 +504,13 @@ public class InboundPlanService {
if (customerId != null && !customerId.equals(plan.getCustomerId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
InboundPlanOrderMapper.ReceivingWarehouseSnapshot receivingWarehouse = orderMapper.findReceivingWarehouse(
plan.getCustomerId(), plan.getSourceOrderId());
if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null
|| trimToNull(receivingWarehouse.overseasWarehouseCode()) == null
|| trimToNull(receivingWarehouse.overseasWarehouseName()) == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单缺少接柜仓预分配事实");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId));
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
@@ -504,8 +520,10 @@ public class InboundPlanService {
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
plan.getContainerNo(),
plan.getContainerNo(), receivingWarehouse.overseasWarehouseCode(), receivingWarehouse.overseasWarehouseName(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(),
allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(),
lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(),
line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(),
line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(),
@@ -28,6 +28,13 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
"THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_id,''))=1 THEN MAX(o.overseas_warehouse_id) ELSE NULL END AS overseasWarehouseId," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_code,''))=1 THEN MAX(o.overseas_warehouse_code) ELSE NULL END AS overseasWarehouseCode," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_name,''))=1 THEN MAX(o.overseas_warehouse_name) ELSE NULL END AS overseasWarehouseName," +
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)=1 THEN MAX(o.receiving_warehouse_version) ELSE -1 END AS assignmentVersion," +
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)=1 AND NOT EXISTS (SELECT 1 FROM ll_receiving_container_session s WHERE s.source_container_booking_id=o.source_container_booking_id) THEN TRUE ELSE FALSE END AS canChangeReceivingWarehouse," +
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)&lt;&gt;1 THEN '柜内接柜仓分配版本不一致' " +
"WHEN EXISTS (SELECT 1 FROM ll_receiving_container_session s WHERE s.source_container_booking_id=o.source_container_booking_id) THEN '海外PDA已建立接柜会话,接柜仓已永久锁定' ELSE NULL END AS receivingWarehouseLockReason," +
"COUNT(*) AS totalOrderCount,COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) AS inboundCodeCount," +
"SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END) AS missingInboundCodeCount," +
"COUNT(DISTINCT p.id) AS planCount," +
@@ -114,6 +121,25 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
List<String> lockContainerNumbers(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT COUNT(*) FROM ll_receiving_container_session WHERE source_container_booking_id=#{sourceContainerBookingId}")
long countReceivingSessions(@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_code=#{warehouseCode}," +
"overseas_warehouse_name=#{warehouseName},receiving_warehouse_version=receiving_warehouse_version+1,updated_at=NOW(3) " +
"WHERE source_container_booking_id=#{sourceContainerBookingId} AND receiving_warehouse_version=#{expectedVersion}")
int updateContainerReceivingWarehouse(@Param("sourceContainerBookingId") String sourceContainerBookingId,
@Param("warehouseId") String warehouseId, @Param("warehouseCode") String warehouseCode,
@Param("warehouseName") String warehouseName, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " +
"SET p.overseas_warehouse_id=#{warehouseId},p.version=p.version+1,p.updated_at=NOW(3) " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId}")
int updateContainerPlanReceivingWarehouse(@Param("sourceContainerBookingId") String sourceContainerBookingId,
@Param("warehouseId") long warehouseId);
@Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}")
long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
@@ -135,13 +161,6 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_name=#{warehouseName}," +
"updated_at=NOW(3) WHERE id=#{orderId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId,
@Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag}," +
"pallet_merge_batch_id=CASE WHEN #{flag} IS NULL THEN NULL ELSE pallet_merge_batch_id END," +
"updated_at=#{updatedAt} " +
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingWarehouseOperationMapper extends BaseMapper<ReceivingWarehouseOperation> {
@Select("SELECT * FROM ll_receiving_warehouse_operation WHERE idempotency_key=#{key} LIMIT 1")
ReceivingWarehouseOperation findByKey(@Param("key") String key);
}
@@ -15,6 +15,8 @@ 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.ReceivingWarehouseOperation;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
@@ -23,6 +25,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.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -37,6 +40,7 @@ import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -60,6 +64,8 @@ public class LastlegOrderService {
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
@Resource private InventoryCommandService inventory;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -99,9 +105,58 @@ public class LastlegOrderService {
orderMapper.findContainerProgressItems(bookingId));
}
@Transactional
public ContainerPlanProgressSummary changeReceivingWarehouse(String sourceContainerBookingId,
String idempotencyKey, LastlegOrderRequests.ChangeReceivingWarehouse request, String operatorName) {
String bookingId = normalizeBookingId(sourceContainerBookingId);
String key = normalizeIdempotencyKey(idempotencyKey);
String requestHash = hash(bookingId + "\n" + request.overseasWarehouseId() + "\n"
+ request.expectedVersion() + "\n" + Objects.toString(request.remark(), ""));
ReceivingWarehouseOperation replay = receivingWarehouseOperations.findByKey(key);
if (replay != null) {
if (!bookingId.equals(replay.getSourceContainerBookingId()) || !requestHash.equals(replay.getRequestHash())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Idempotency-Key 已用于其他接柜仓修改请求");
}
return requireFrozenContainer(orderMapper.findContainerProgress(bookingId));
}
List<LastlegOrder> orders = orderMapper.lockContainerOrders(bookingId);
if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
int version = requireSingleAssignmentVersion(orders);
if (version != request.expectedVersion()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓分配版本已变化,请刷新后重试");
}
if (orderMapper.countReceivingSessions(bookingId) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"海外PDA已建立接柜会话,接柜仓已永久锁定");
}
WarehouseResourceGateway.WarehouseReference warehouse =
warehouseResourceGateway.requireActiveSharedWarehouse(request.overseasWarehouseId());
int updated = orderMapper.updateContainerReceivingWarehouse(bookingId,
Long.toString(warehouse.warehouseId()), warehouse.warehouseCode(), warehouse.warehouseName(), version);
if (updated != orders.size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配发生并发变化");
}
orderMapper.updateContainerPlanReceivingWarehouse(bookingId, warehouse.warehouseId());
ReceivingWarehouseOperation operation = new ReceivingWarehouseOperation();
operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId);
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(version + 1); operation.setOperatorName(operatorName);
operation.setRemark(request.remark() == null ? null : request.remark().trim());
operation.setCreatedAt(LocalDateTime.now()); receivingWarehouseOperations.insert(operation);
return requireFrozenContainer(orderMapper.findContainerProgress(bookingId));
}
private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) {
if (summary == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
try {
ContainerNumbers.requireCanonical(summary.containerNo());
if (!StringUtils.hasText(summary.overseasWarehouseId())
|| !StringUtils.hasText(summary.overseasWarehouseCode())
|| !StringUtils.hasText(summary.overseasWarehouseName()) || summary.assignmentVersion() < 0) {
throw new IllegalArgumentException("receiving warehouse assignment drift");
}
return summary;
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
@@ -109,6 +164,42 @@ public class LastlegOrderService {
}
}
private String normalizeBookingId(String value) {
String result = value == null ? null : value.trim();
if (!StringUtils.hasText(result) || result.length() > 128) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空");
}
return result;
}
private String normalizeIdempotencyKey(String value) {
String result = value == null ? null : value.trim();
if (!StringUtils.hasText(result) || result.length() > 255) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必填且不得超过255字符");
}
return result;
}
private int requireSingleAssignmentVersion(List<LastlegOrder> orders) {
Integer version = orders.get(0).getReceivingWarehouseVersion();
if (version == null || orders.stream().anyMatch(order -> !Objects.equals(version,
order.getReceivingWarehouseVersion()) || !StringUtils.hasText(order.getOverseasWarehouseId())
|| !StringUtils.hasText(order.getOverseasWarehouseCode())
|| !StringUtils.hasText(order.getOverseasWarehouseName()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配事实不一致");
}
return version;
}
private String hash(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId);
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
@@ -434,7 +525,7 @@ public class LastlegOrderService {
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
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.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
@@ -30,6 +30,9 @@ import com.fasterxml.jackson.databind.JsonNode;
@Service
public class LoadingCompletedConsumer {
static final String DEFAULT_RECEIVING_WAREHOUSE_ID = "193000000000200003";
static final String DEFAULT_RECEIVING_WAREHOUSE_CODE = "US-LAX-01";
static final String DEFAULT_RECEIVING_WAREHOUSE_NAME = "洛杉矶仓";
@Resource private LastlegEventInboxMapper inboxMapper;
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegInboundOrderMapper inboundOrderMapper;
@@ -240,7 +243,10 @@ public class LoadingCompletedConsumer {
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
order.setCustomerName(customerName);
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
order.setOverseasWarehouseId(DEFAULT_RECEIVING_WAREHOUSE_ID);
order.setOverseasWarehouseCode(DEFAULT_RECEIVING_WAREHOUSE_CODE);
order.setOverseasWarehouseName(DEFAULT_RECEIVING_WAREHOUSE_NAME);
order.setReceivingWarehouseVersion(0);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo);
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount());
@@ -58,8 +58,10 @@ CREATE TABLE IF NOT EXISTS ll_order (
customer_name VARCHAR(255) COMMENT '货主名称',
source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照',
source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照',
overseas_warehouse_id VARCHAR(128) COMMENT '海外实际收货仓 ID,由绑定仓库的PDA操作员认领柜号时冻结',
overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称',
overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓',
overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照',
overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照',
receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID',
transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码',
transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称',
@@ -177,6 +179,20 @@ 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_receiving_warehouse_operation (
id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID',
idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要',
result_version INT NOT NULL COMMENT '修改成功后的柜级分配版本',
operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称',
remark VARCHAR(500) NULL COMMENT '接柜仓修改备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_warehouse_idempotency(idempotency_key),
KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at)
) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录';
-- 派送单列表与详情快照、转派送费用及操作记录。
CREATE TABLE IF NOT EXISTS ll_delivery_order (
id BIGINT NOT NULL COMMENT '派送单主键ID',
@@ -357,8 +373,10 @@ ALTER TABLE ll_order
MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号',
MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID',
MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓 ID',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓',
MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照',
MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码',
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号',
MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态',
@@ -485,7 +503,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结',
overseas_warehouse_id BIGINT NOT NULL DEFAULT 193000000000200003 COMMENT '平台预分配的海外实际收货共享物理仓ID',
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量',
@@ -851,7 +869,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
@@ -298,6 +298,23 @@ paths:
code: { type: string, enum: ['200'] }
data: { $ref: '#/components/schemas/ContainerPlanProgressDetail' }
'404': { description: 稳定柜 ID 不存在 }
/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse:
put:
summary: 修改整柜海外接柜仓
description: 权限 platform:overseas-warehouse:order:edit;目标必须为国内 Warehouse Service 有效 OVERSEAS 仓。使用柜级版本和 Idempotency-Key,事务内原子同步柜内全部尾程订单及已有入库计划;海外PDA一旦建立接柜会话即永久锁定。
security: [{ bearerAuth: [] }]
parameters:
- { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 255 } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ChangeReceivingWarehouseRequest' }
responses:
'200': { description: 更新后的柜级摘要, content: { application/json: { schema: { type: object, properties: { code: { type: string, enum: ['200'] }, data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } } } } }
'400': { description: 请求无效或目标不是有效 OVERSEAS 仓 }
'409': { description: 版本冲突、幂等键冲突或PDA接柜会话已建立 }
/api/platform/v1/last-leg/orders/{id}/destination:
put:
summary: 将单票尾程订单转入 FBA 目的仓
@@ -331,6 +348,39 @@ paths:
'200': { description: 统一 ApiResponse 包装的更新订单数 }
'400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 }
'503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 }
/api/platform/v1/last-leg/inbound-plans:
get:
summary: 平台分页查询海外入库订单
description: 权限 platform:overseas-warehouse:inbound-order:list;列表中的 overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。
security: [{ bearerAuth: [] }]
parameters:
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: planNo, in: query, schema: { type: string } }
- { name: inboundCode, in: query, schema: { type: string } }
- { name: lxCode, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: overseasWarehouseId, in: query, schema: { type: string } }
- { name: destinationWarehouseCode, in: query, schema: { type: string } }
- { name: warehouse, in: query, schema: { type: string } }
- { name: fbaWarehouseCode, in: query, schema: { type: string } }
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } }
- { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 平台入库订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanPageResponse' } } } } }
/api/platform/v1/last-leg/inbound-plans/{planNo}:
get:
summary: 平台查询海外入库订单详情
description: 权限 platform:overseas-warehouse:inbound-order:list;详情中的 overseasWarehouseCode/overseasWarehouseName 只取当前计划来源尾程订单的预分配接柜仓事实。
security: [{ bearerAuth: [] }]
parameters:
- { name: planNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
responses:
'200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }
'404': { description: 入库订单不存在或不属于指定客户 }
/api/platform/v1/last-leg/inbound-plans/convert:
post:
summary: 平台生成入库计划
@@ -811,7 +861,7 @@ paths:
/api/merchant/v1/last-leg/inbound-plans:
get:
summary: 客户分页查询自己的海外入库订单
description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId;overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -829,7 +879,7 @@ paths:
/api/merchant/v1/last-leg/inbound-plans/{planNo}:
get:
summary: 客户查询自己的海外入库订单详情
description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId。
description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId;overseasWarehouseCode/overseasWarehouseName 只取该计划来源尾程订单的预分配接柜仓事实。
security: [{ bearerAuth: [] }]
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }, '404': { description: 当前客户下入库订单不存在 } }
@@ -1093,10 +1143,16 @@ components:
items: { type: string, pattern: '^[1-9][0-9]*$' }
ContainerPlanProgressSummary:
type: object
required: [sourceContainerBookingId, containerNo, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
properties:
sourceContainerBookingId: { type: string }
containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 }
overseasWarehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 平台预分配的海外接柜仓ID,不是FBA目的仓 }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码 }
overseasWarehouseName: { type: string, description: 海外接柜仓业务名称 }
assignmentVersion: { type: integer, minimum: 0, description: 柜级接柜仓乐观锁版本 }
canChangeReceivingWarehouse: { type: boolean }
receivingWarehouseLockReason: { type: string, nullable: true }
totalOrderCount: { type: integer, format: int64 }
inboundCodeCount: { type: integer, format: int64 }
missingInboundCodeCount: { type: integer, format: int64 }
@@ -1106,6 +1162,13 @@ components:
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at }
allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true }
blockingReason: { type: string, nullable: true }
ChangeReceivingWarehouseRequest:
type: object
required: [overseasWarehouseId, expectedVersion]
properties:
overseasWarehouseId: { type: integer, format: int64, minimum: 1 }
expectedVersion: { type: integer, minimum: 0 }
remark: { type: string, maxLength: 500, nullable: true }
ContainerPlanProgressDetail:
type: object
required: [summary, orders]
@@ -1329,10 +1392,12 @@ components:
warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 }
ReceivingCandidate:
type: object
required: [sourceContainerBookingId, containerNo, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
required: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
properties:
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
containerNo: { type: string }
overseasWarehouseCode: { type: string }
overseasWarehouseName: { type: string }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
totalOrderCount: { type: integer, minimum: 1 }
missingInboundCodeCount: { type: integer, minimum: 0 }
@@ -1351,10 +1416,11 @@ components:
currentAccount: { type: boolean }
ReceivingSession:
type: object
required: [containerNo, overseasWarehouseId, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
properties:
containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: PDA Token绑定并在认领柜号时冻结的共享实际海外收货仓ID }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
operatorName: { type: string, nullable: true }
@@ -1911,7 +1977,7 @@ components:
properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } }
InboundPlanSummary:
type: object
required: [lineId, planId, planNo, customerId, orderId, inboundCode, packageCount, pickedPackageCount, palletCount, status, createdAt, version]
required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, palletCount, status, createdAt, version]
properties:
lineId: { type: string, pattern: '^[0-9]+$' }
planId: { type: string, pattern: '^[0-9]+$' }
@@ -1920,6 +1986,8 @@ components:
orderId: { type: string, pattern: '^[0-9]+$' }
inboundCode: { type: string }
orderType: { type: string, nullable: true }
overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }
overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }
warehouseId: { type: string, nullable: true }
warehouseCode: { type: string, nullable: true }
warehouseName: { type: string, nullable: true }
@@ -1939,13 +2007,15 @@ components:
version: { type: integer }
InboundPlanDetail:
type: object
required: [id, planNo, customerId, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
properties:
id: { type: string, pattern: '^[0-9]+$' }
planNo: { type: string }
customerId: { type: string }
sourceOrderId: { type: string, pattern: '^[0-9]+$', nullable: true }
containerNo: { type: string, nullable: true }
overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }
overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }
status: { type: string }
lineCount: { type: integer }
palletCount: { type: integer }
@@ -35,6 +35,20 @@ class DispatchCandidateMapperContractTest {
assertTrue(sql.contains("pos.available_packages &gt; 0"));
}
@Test
void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception {
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
assertTrue(sql.contains("pos.overseas_warehouse_id"));
assertTrue(sql.contains("CAST(ip.overseas_warehouse_id AS CHAR)"));
assertTrue(sql.contains("ll_inventory_balance"));
}
}
private void assertConstructorColumnOrder(Method method) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
int previous = -1;
@@ -127,6 +127,21 @@ class DispatchServiceTest {
verify(items).insert(any(DispatchItem.class));
}
@Test
void preArrangeRejectsPalletWithoutResolvedOverseasWarehouseWithPreciseReason() {
DispatchPalletCandidate unresolved = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
null, null, "LMO1", "LMO1", "RK1", "LIP1-P001", "ONT8", "ONT8", "ONT8", "ONT8",
12, new BigDecimal("1.2000"), "RECEIVED");
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(unresolved));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange("100", "7",
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
assertEquals("所选托盘中存在尚未关联海外仓的托盘", exception.getReason());
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
@@ -17,5 +17,21 @@ class InboundPlanOrderMapperContractTest {
assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,'')))"));
assertTrue(sql.contains("THEN 1 ELSE COUNT(DISTINCT"));
assertTrue(sql.contains("0 AS destinationMismatchCount"));
assertTrue(sql.contains("o.overseas_warehouse_id AS overseasWarehouseId"));
assertTrue(sql.contains("o.overseas_warehouse_code AS overseasWarehouseCode"));
assertTrue(sql.contains("o.overseas_warehouse_name AS overseasWarehouseName"));
}
@Test
void receivingWarehouseSnapshotIsReadFromTenantScopedOrderFact() throws Exception {
Select select = InboundPlanOrderMapper.class
.getMethod("findReceivingWarehouse", String.class, Long.class)
.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertTrue(sql.contains("overseas_warehouse_code AS overseasWarehouseCode"));
assertTrue(sql.contains("overseas_warehouse_name AS overseasWarehouseName"));
assertTrue(sql.contains("customer_id=#{customerId} AND id=#{orderId}"));
assertTrue(!sql.contains("destination_warehouse"));
}
}
@@ -112,9 +112,6 @@ class ReceivingContainerServiceBehaviorTest {
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
when(plans.claimReceivingWarehouse("100", 2L, 100L)).thenReturn(1);
InboundPlan claimed = plan("CONT-A"); claimed.setOverseasWarehouseId(100L);
when(plans.selectById(2L)).thenReturn(claimed);
var response = service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("same-key"));
assertThat(response.overseasWarehouseId()).isEqualTo(100L);
@@ -652,7 +649,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.setStatus("SIGNED"); value.setOverseasWarehouseId(100L);
return value;
}
@@ -668,7 +665,9 @@ class ReceivingContainerServiceBehaviorTest {
private static LastlegOrder order(String destinationType) {
LastlegOrder value = new LastlegOrder();
value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType);
value.setOverseasWarehouseId("100"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
value.setOverseasWarehouseId("100"); value.setOverseasWarehouseCode("SHARED-LAX");
value.setOverseasWarehouseName("Shared LAX");
value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
return value;
}
@@ -690,7 +689,8 @@ class ReceivingContainerServiceBehaviorTest {
private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseName("Shared LAX");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX");
value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0);
return value;
}
@@ -48,9 +48,9 @@ class InboundPlanPaginationTest {
null, "PENDING_ASSIGNMENT", null, null, "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED",
LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59));
InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100",
3L, "IN-1", "FIRST_LEG", "W1", "ONT", "安大略仓", "ONT8", "客户A", 10,
BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", null, null, null, null, "UNSCHEDULED",
LocalDateTime.of(2026, 8, 20, 10, 0));
3L, "IN-1", null, "FIRST_LEG", "US-LAX-01", "洛杉矶仓", "W1", "ONT", "安大略仓",
"ONT8", "客户A", 10, 0, BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", null, null, null,
null, "UNSCHEDULED", LocalDateTime.of(2026, 8, 20, 10, 0), 0);
when(queryMapper.count("100", query)).thenReturn(11L);
when(queryMapper.rows("100", query, 10, 10)).thenReturn(List.of(row));
@@ -59,6 +59,8 @@ class InboundPlanPaginationTest {
assertThat(result.records()).containsExactly(row);
assertThat(result.records().get(0).lineId()).isEqualTo(9007199254740993L);
assertThat(result.records().get(0).appointmentStatus()).isNull();
assertThat(result.records().get(0).overseasWarehouseCode()).isEqualTo("US-LAX-01");
assertThat(result.records().get(0).overseasWarehouseName()).isEqualTo("洛杉矶仓");
assertThat(result.pages()).isEqualTo(2);
verify(queryMapper).count("100", query);
verify(queryMapper).rows("100", query, 10, 10);
@@ -71,6 +73,10 @@ class InboundPlanPaginationTest {
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER;
assertThat(sql).contains("a2.customer_id=#{customerId}", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1");
assertThat(sql).contains("di.customer_id=#{customerId} AND </if>di.order_id=l.order_id");
assertThat(sql).contains("o.overseas_warehouse_code overseasWarehouseCode",
"o.overseas_warehouse_name overseasWarehouseName",
"o.overseas_warehouse_id=#{q.overseasWarehouseId}");
assertThat(sql).doesNotContain("l.destination_warehouse_id=#{q.overseasWarehouseId}");
assertThat(sql).contains("q.planNo", "q.inboundCode", "q.status", "q.overseasWarehouseId",
"q.destinationWarehouseCode", "q.warehouse",
"q.fbaWarehouseCode", "q.appointmentStatus", "q.dispatchStatus", "q.createdFrom", "q.createdTo");
@@ -64,6 +64,9 @@ class InboundPlanServiceTest {
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(orderMapper.findReceivingWarehouse(any(), any())).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot(
"193000000000200003", "US-LAX-01", "洛杉矶仓"));
}
@Test
@@ -105,12 +108,15 @@ class InboundPlanServiceTest {
verify(orderMapper).lockOrder("100", 2L);
assertEquals(2L, result.sourceOrderId());
assertEquals("MSCU6639870", result.containerNo());
assertEquals("US-LAX-01", result.overseasWarehouseCode());
assertEquals("洛杉矶仓", result.overseasWarehouseName());
}
@Test
void convertsAConsistentDestinationAssignedOrder() {
SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10,
"DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
"2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
@@ -148,6 +154,32 @@ class InboundPlanServiceTest {
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
}
@Test
void detailReturnsReceivingWarehouseFromTenantScopedOrderFact() {
InboundPlan plan = existingPlan("100", 1L, "LIP-1");
when(planMapper.selectOne(any())).thenReturn(plan);
when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot("88", "US-NY-01", "纽约仓"));
var result = service.detailByPlanNo("100", "LIP-1");
assertEquals("US-NY-01", result.overseasWarehouseCode());
assertEquals("纽约仓", result.overseasWarehouseName());
verify(orderMapper).findReceivingWarehouse("100", 1L);
}
@Test
void detailFailsClosedWhenReceivingWarehouseFactIsMissing() {
InboundPlan plan = existingPlan("100", 1L, "LIP-1");
when(planMapper.selectOne(any())).thenReturn(plan);
when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn(null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.detailByPlanNo("100", "LIP-1"));
assertEquals(409, error.getStatusCode().value());
}
@Test
void rejectsReversedInboundPlanDateRange() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null,
@@ -160,7 +192,9 @@ class InboundPlanServiceTest {
@Test
void rejectsMissingDestinationWarehouse() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "MSCU6639870", "RK-1",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0);
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
null, null, null, 1, 0);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
@@ -190,7 +224,9 @@ class InboundPlanServiceTest {
@Test
void transferWarehouseOverridesOriginalCargoDestination() {
SourceOrderSnapshot transferred = new SourceOrderSnapshot(1L, "100", "MSCU6639870", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
"WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(transferred));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
@@ -332,7 +368,9 @@ class InboundPlanServiceTest {
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD")));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0)));
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
null, null, null, 1, 0)));
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
@@ -419,12 +457,14 @@ class InboundPlanServiceTest {
private SourceOrderSnapshot order(String customerId, Long id, String inboundCode, String warehouse) {
return new SourceOrderSnapshot(id, customerId, "MSCU6639870", inboundCode, "PENDING_ASSIGNMENT", null,
BigDecimal.ONE, 10, warehouse, warehouse, warehouse, 1, 0);
BigDecimal.ONE, 10, 0, "193000000000200003", "US-LAX-01", "洛杉矶仓", null,
warehouse, warehouse, warehouse, 1, 0);
}
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "MSCU6639870", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
0, "193000000000200003", "US-LAX-01", "洛杉矶仓", null,
warehouse, warehouse, warehouse, 1, 0);
}
}
@@ -35,8 +35,14 @@ class MerchantOverseasOpenApiContractTest {
.contains("#/components/schemas/TruckDetailResponse")
.contains("#/components/schemas/LooseboardDetailResponse")
.contains("#/components/schemas/InboundPlanDetailResponse")
.contains("overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }")
.contains("overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }")
.contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE")
.contains("version 必填").contains("幂等冲突");
assertThat(yaml).contains("/api/platform/v1/last-leg/inbound-plans:")
.contains("/api/platform/v1/last-leg/inbound-plans/{planNo}:")
.contains("overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实")
.contains("客户范围只取认证 Token,不接收 customerId");
}
private static String pathBlock(String yaml, String path) {
@@ -17,7 +17,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class SchemaChineseCommentContractTest {
private static final Set<String> MODULE_BASELINE_ONLY_TABLES = Set.of(
"ll_us_truck_order_operation", "ll_local_looseboard_operation");
"ll_us_truck_order_operation", "ll_local_looseboard_operation",
"ll_receiving_warehouse_operation");
private static final Set<String> MODULE_BASELINE_ONLY_COLUMNS = Set.of(
"ll_order.overseas_warehouse_code", "ll_order.receiving_warehouse_version",
"ll_receiving_container_session.overseas_warehouse_code");
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
private static final Pattern TABLE = Pattern.compile(
@@ -138,6 +142,9 @@ class SchemaChineseCommentContractTest {
continue;
}
for (String column : table.columns()) {
if (MODULE_BASELINE_ONLY_COLUMNS.contains(table.name() + "." + column)) {
continue;
}
boolean repaired = migration.contains("table_name='" + table.name()
+ "' AND column_name='" + column + "'");
boolean addedWithComment = hasCommentedAdd(migration, column)
@@ -23,7 +23,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class ContainerPlanProgressContractTest {
@Test
void responseFieldsAndServerAggregateStayFrozen() throws Exception {
assertEquals(List.of("sourceContainerBookingId", "containerNo", "totalOrderCount", "inboundCodeCount",
assertEquals(List.of("sourceContainerBookingId", "containerNo", "overseasWarehouseId",
"overseasWarehouseCode", "overseasWarehouseName", "assignmentVersion",
"canChangeReceivingWarehouse", "receivingWarehouseLockReason", "totalOrderCount", "inboundCodeCount",
"missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount",
"plannedArrivalAt", "allPlansReady", "blockingReason"),
Arrays.stream(ContainerPlanProgressSummary.class.getRecordComponents())
@@ -14,6 +14,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -38,6 +40,7 @@ import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@@ -53,6 +56,8 @@ class LastlegOrderServiceTest {
@Mock FbaWarehouseValidator fbaWarehouses;
@Mock InventoryCommandService inventory;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
@Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Mock WarehouseResourceGateway warehouseResourceGateway;
LastlegOrderService service;
@BeforeEach
@@ -71,6 +76,50 @@ class LastlegOrderServiceTest {
"US", "CA"));
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations);
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
}
@Test
void changesReceivingWarehouseForWholeContainerBeforePdaSession() {
LastlegOrder first = order(1L, "100", "PENDING_ASSIGNMENT", null);
LastlegOrder second = order(2L, "200", "PENDING_ASSIGNMENT", null);
for (LastlegOrder value : List.of(first, second)) {
value.setReceivingWarehouseVersion(0); value.setOverseasWarehouseId("193000000000200003");
value.setOverseasWarehouseCode("US-LAX-01"); value.setOverseasWarehouseName("洛杉矶仓");
}
var updated = new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "900",
"US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 0, 0, 2,
null, false, "存在2票尚未生成有效入库计划");
when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second));
when(warehouseResourceGateway.requireActiveSharedWarehouse(900L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(900L, "US-NYC-01", "纽约仓", "ACTIVE"));
when(orders.updateContainerReceivingWarehouse("BOOKING-1", "900", "US-NYC-01", "纽约仓", 0))
.thenReturn(2);
when(orders.findContainerProgress("BOOKING-1")).thenReturn(updated);
var result = service.changeReceivingWarehouse("BOOKING-1", "change-1",
new LastlegOrderRequests.ChangeReceivingWarehouse(900L, 0, "改派纽约"), "admin");
assertEquals("US-NYC-01", result.overseasWarehouseCode());
verify(orders).updateContainerPlanReceivingWarehouse("BOOKING-1", 900L);
verify(receivingWarehouseOperations).insert(any(com.baiyue.lastleg.entity.ReceivingWarehouseOperation.class));
}
@Test
void permanentlyLocksReceivingWarehouseAfterPdaSessionExists() {
LastlegOrder order = order(1L, "100", "PENDING_ASSIGNMENT", null);
order.setReceivingWarehouseVersion(0); order.setOverseasWarehouseId("193000000000200003");
order.setOverseasWarehouseCode("US-LAX-01"); order.setOverseasWarehouseName("洛杉矶仓");
when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(order));
when(orders.countReceivingSessions("BOOKING-1")).thenReturn(1L);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
service.changeReceivingWarehouse("BOOKING-1", "change-2",
new LastlegOrderRequests.ChangeReceivingWarehouse(900L, 0, null), "admin"));
assertEquals(409, error.getStatusCode().value());
verifyNoInteractions(warehouseResourceGateway);
}
@Test
@@ -283,7 +332,8 @@ class LastlegOrderServiceTest {
}
private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) {
return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", 15, 15, 0,
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);
}
@@ -5,7 +5,7 @@ class LoadingCompletedConsumerTest {
private final Map<String,String> frozenContainerNos=new HashMap<>();
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
+32 -2
View File
@@ -126,7 +126,12 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目'
Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录'
Assert-Contains $testTaskRegistry '当前无已登记总控或长期专项。' '测试登记清空状态'
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
Assert-Contains $testTaskRegistry '`01a046ea-cfc4-73a1-85f0-c69c714bff22`' '测试环境海外接柜仓总控'
Assert-Contains $testTaskRegistry '【测试专项】海外接柜后端' '测试环境海外接柜后端专项'
Assert-Contains $testTaskRegistry '【测试专项】海外接柜Admin' '测试环境海外接柜 Admin 专项'
Assert-Contains $testTaskRegistry '【测试专项】海外接柜Client' '测试环境海外接柜 Client 专项'
Assert-Contains $testTaskRegistry '【测试专项】海外接柜PDA' '测试环境海外接柜 PDA 专项'
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
Assert-NotContains $testTaskRegistry '01a05b10-a5fc-7d51-8203-c671f3a823ad' '测试登记开发总控'
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
@@ -136,6 +141,31 @@ Assert-Contains $devTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global-
Assert-Contains $devTaskRegistry '`01a06c0b-0515-7c60-b329-77342b54e666`' '开发环境全流程总控'
Assert-NotContains $devTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '开发登记测试总控'
Assert-NotContains $devTaskRegistry 'c64729ff-6710-43c6-8ef2-d84e9666cc1e' '开发登记测试 projectId'
# 业务文档已由开发批次目录收敛为稳定业务域,根快速路由不得继续引用已删除阶段目录。
foreach ($businessDocsEntry in @(
'docs/订单与客户/README.md',
'docs/入库测量与计费/README.md',
'docs/国内货柜履约/README.md',
'docs/海外仓业务/README.md'
)) {
Assert-Contains $rootAgents $businessDocsEntry "根业务域入口 $businessDocsEntry"
}
foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三期/', 'docs/四期/')) {
Assert-NotContains $rootAgents $removedPhaseDocsEntry "根已删除阶段目录 $removedPhaseDocsEntry"
}
Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围'
# 新增稳定业务能力必须从根表和目标部署单元直接命中。
Assert-Contains $rootAgents '入库扣货、解除与凭证' '根入库扣货路由'
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由'
Assert-Contains $backendAgents '入库扣货、解除、履约门禁与凭证' '后端入库扣货路由'
Assert-Contains $rootAgents '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由'
Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由'
Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结与结算' '后端客户资金账户路由'
Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照'
Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照'
$testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
$devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) {
@@ -655,7 +685,7 @@ $financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/m
$financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java')
Assert-Contains $rootAgents '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
Assert-Contains $rootAgents '海外业务费用来源与客户财务' '根海外费用快速路由'
Assert-Contains $adminAgents '财务与柜级公共费用分摊' 'Admin 柜费分摊快速路由'
Assert-Contains $adminAgents '财务、客户资金账户与柜级公共费用分摊' 'Admin 柜费分摊快速路由'
Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由'
Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由'
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'