Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	order/AGENTS.md
This commit is contained in:
2026-09-04 12:29:57 +08:00
87 changed files with 3134 additions and 720 deletions
+2 -1
View File
@@ -31,7 +31,8 @@
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File |
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
| 全局智能装柜候选、柜次与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/group-assignments`,业务编排 `service/SmartLoadingService.java`;渠道分组由服务端映射,同柜不可拆组作为一个分页与分配单元 |
| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 查询 Order `PendingReviewOrderQueryService.java`。候选四态只用于筛选展示,资格以 `canAssign` 为准;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 |
| 客户入库结果与头程费用确认 | `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` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
+132 -10
View File
@@ -3195,22 +3195,31 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
block_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse整票连续库位块ID',
slot_no INT UNSIGNED NOT NULL COMMENT '连续块内稳定槽位序号',
weight_result_id BIGINT UNSIGNED NULL COMMENT '已绑定称重结果ID;预留空槽为空',
pallet_sequence INT UNSIGNED NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照',
pallet_id BIGINT UNSIGNED NULL COMMENT '仓库服务托盘ID;预留空槽为空',
pallet_code VARCHAR(50) NULL COMMENT '托盘编码快照;预留空槽为空',
pallet_capacity_cbm DECIMAL(18,4) NULL COMMENT '托盘可用容积快照;预留空槽为空',
allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通连续位不足时是否使用共享A0',
reservation_id BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位',
slot_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no),
UNIQUE KEY uk_pallet_allocation_weight (tenant_id, weight_result_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
KEY idx_allocation_reservation (reservation_id),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用';
@@ -10470,11 +10479,58 @@ CALL phase3_pda_add_column(
'fl_loading_pick_item', 'release_location_ids',
"JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个' AFTER release_mode"
);
CALL phase3_pda_add_column(
'fl_inbound_pallet_allocation', 'block_id',
"BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse整票连续库位块ID' AFTER allocation_no"
);
CALL phase3_pda_add_column(
'fl_inbound_pallet_allocation', 'slot_no',
"INT UNSIGNED NOT NULL COMMENT '连续块内稳定槽位序号' AFTER block_id"
);
CALL phase3_pda_add_column(
'fl_inbound_pallet_allocation', 'weight_result_id',
"BIGINT UNSIGNED NULL COMMENT '已绑定称重结果ID;预留空槽为空' AFTER slot_no"
);
CALL phase3_pda_add_column(
'fl_inbound_pallet_allocation', 'pallet_sequence',
"INT UNSIGNED NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空' AFTER weight_result_id"
);
CALL phase3_pda_add_column(
'fl_inbound_pallet_allocation', 'shared_overflow',
"TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通连续位不足时是否使用共享A0' AFTER location_code"
);
CALL phase3_pda_add_column(
'fl_inbound_pallet_allocation', 'reservation_id',
"BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code"
"BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位' AFTER shared_overflow"
);
CALL phase3_pda_add_column(
'fl_inbound_pallet_allocation', 'slot_status',
"VARCHAR(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED' AFTER reservation_id"
);
DROP PROCEDURE IF EXISTS phase3_pda_add_column;
-- 当前开发基线直接收敛为整票块槽位;旧预占单号不再保留。
ALTER TABLE fl_inbound_pallet_allocation
MODIFY COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '仓库服务托盘ID;预留空槽为空',
MODIFY COLUMN pallet_code VARCHAR(50) NULL COMMENT '托盘编码快照;预留空槽为空',
MODIFY COLUMN pallet_capacity_cbm DECIMAL(18,4) NULL COMMENT '托盘可用容积快照;预留空槽为空',
MODIFY COLUMN reservation_id BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位';
SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_inbound_pallet_allocation' AND column_name='reservation_no') > 0,
'ALTER TABLE fl_inbound_pallet_allocation DROP COLUMN reservation_no', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_inbound_pallet_allocation' AND index_name='uk_pallet_allocation_slot') = 0,
'ALTER TABLE fl_inbound_pallet_allocation ADD UNIQUE KEY uk_pallet_allocation_slot (tenant_id,block_id,slot_no)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_inbound_pallet_allocation' AND index_name='uk_pallet_allocation_weight') = 0,
'ALTER TABLE fl_inbound_pallet_allocation ADD UNIQUE KEY uk_pallet_allocation_weight (tenant_id,weight_result_id)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_inbound_pallet_allocation' AND index_name='idx_allocation_reservation') = 0,
'ALTER TABLE fl_inbound_pallet_allocation ADD KEY idx_allocation_reservation (reservation_id)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
-- PDA 多人协作:领取粒度由整柜任务下沉为单条拣货明细。
CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment (
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID,同时作为本表主键',
@@ -13704,21 +13760,26 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'al
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'allocated_volume_cbm', '本托盘分配体积');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'allocation_no', '入库单内分配序号');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'allocation_status', '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'block_id', 'Warehouse整票连续库位块ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'created_at', '创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'id', '头程托盘分配引用ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'inbound_id', '入库单ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'location_code', '库位编码快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'location_id', '仓库服务库位ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_capacity_cbm', '托盘可用容积快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_code', '托盘编码快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_id', '仓库服务托盘ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_capacity_cbm', '托盘可用容积快照;预留空槽为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_code', '托盘编码快照;预留空槽为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_id', '仓库服务托盘ID;预留空槽为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_sequence', '已绑定称重托盘在整票内的稳定顺序;空槽为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'picked_package_count', '已拣货箱数');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'reservation_id', '仓库预占资源唯一ID,A0共享场景用于区分托盘');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'reservation_no', '仓库库位预占单号');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'reservation_id', '仓库预占资源唯一ID,A0共享场景用于区分槽位');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'shared_overflow', '普通连续位不足时是否使用共享A0');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'slot_no', '连续块内稳定槽位序号');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'slot_status', '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'tenant_id', '客户ID和数据隔离键');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'updated_at', '更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_id', '仓库服务仓库ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'weight_result_id', '已绑定称重结果ID;预留空槽为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pickup_snapshot', 'address_line', '提货详细地址');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pickup_snapshot', 'city', '城市');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pickup_snapshot', 'contact_email', '提货联系人邮箱');
@@ -14927,6 +14988,45 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_opera
CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'source_service', '来源服务');
CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'target_business_id', '目标子入仓单ID');
CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'tenant_id', '仓储公共资源域,固定为0');
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_location_block (
id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
warehouse_id BIGINT UNSIGNED NOT NULL, source_service VARCHAR(40) NOT NULL,
business_id BIGINT UNSIGNED NOT NULL, inbound_code VARCHAR(40) NOT NULL,
slot_count INT UNSIGNED NOT NULL, idempotency_key VARCHAR(128) NOT NULL,
block_status VARCHAR(20) NOT NULL, expires_at DATETIME(3) NOT NULL,
last_operation_key VARCHAR(128) NULL, created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
active_business_block TINYINT GENERATED ALWAYS AS
(CASE WHEN block_status IN ('RESERVED','OCCUPIED') THEN 1 ELSE NULL END) STORED,
PRIMARY KEY (id),
UNIQUE KEY uk_location_block_idempotency (tenant_id, source_service, idempotency_key),
UNIQUE KEY uk_location_block_active_business (tenant_id, source_service, business_id, active_business_block),
KEY idx_location_block_business (tenant_id, source_service, business_id, block_status),
CONSTRAINT fk_location_block_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_location_block_slot_count CHECK (slot_count BETWEEN 1 AND 200)
) ENGINE=InnoDB COMMENT='一票入仓码原子持有的稳定库位块';
CREATE TABLE IF NOT EXISTS wh_location_block_slot (
id BIGINT UNSIGNED NOT NULL COMMENT '槽位预占ID,对外作为reservationId',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, block_id BIGINT UNSIGNED NOT NULL,
slot_no INT UNSIGNED NOT NULL, location_id BIGINT UNSIGNED NOT NULL,
location_code VARCHAR(50) NOT NULL, shared_overflow TINYINT(1) NOT NULL DEFAULT 0,
resource_status VARCHAR(20) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
active_normal_slot TINYINT GENERATED ALWAYS AS
(CASE WHEN resource_status IN ('RESERVED','OCCUPIED') AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED,
PRIMARY KEY (id), UNIQUE KEY uk_location_block_slot_no (tenant_id, block_id, slot_no),
UNIQUE KEY uk_location_block_active_normal (tenant_id, location_id, active_normal_slot),
KEY idx_location_block_slot_block (tenant_id, block_id, resource_status),
CONSTRAINT fk_location_block_slot_block FOREIGN KEY (block_id) REFERENCES wh_location_block (id),
CONSTRAINT fk_location_block_slot_location FOREIGN KEY (location_id) REFERENCES wh_location (id)
) ENGINE=InnoDB COMMENT='整票库位块槽位;空槽也由当前入仓码独占';
USE baiyue_firstleg;
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'address_line', '详细地址');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code', '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'city', '城市');
@@ -15160,6 +15260,28 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:同票托盘唯一约束仍阻止托盘主数据复用';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_pallet_allocation'
AND column_name IN ('block_id','slot_no','weight_result_id','pallet_sequence',
'shared_overflow','reservation_id','slot_status')) <> 7
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_pallet_allocation'
AND column_name = 'reservation_no') <> 0
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_pallet_allocation'
AND column_name IN ('pallet_id','pallet_code','pallet_capacity_cbm')
AND is_nullable = 'YES') <> 3
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_pallet_allocation'
AND index_name IN ('uk_pallet_allocation_slot','uk_pallet_allocation_weight')
AND non_unique = 0) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:整票库位块槽位字段、空槽语义或唯一索引未正确收敛';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_carton_label') <> 1 THEN
+8 -5
View File
@@ -27,13 +27,14 @@
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` |
| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` |
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;全局候选、工作台柜次和箱数分配分别从 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments` 进入,同柜组从 `/group-assignments` 整组处理,明细规格拆分留在服务端 |
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选与工作台柜次从 `/inbound-candidates`、`/containers` 进入,普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
@@ -79,12 +80,14 @@
## 三期装柜与 PDA 资源结算
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入由有效柜次占用、剩余箱数、国内中转仓、实际渠道、容量、组锁和版本等事实决定;容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元,不能跨页或拆分。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、国内中转仓、容量、组锁和版本等事实决定;人工加入允许订单渠道与柜次渠道不同,智能推荐仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
- 部分拣货资源结算必须调用 Warehouse 的 `/internal/warehouse/v1/pallet-locations/settlements` 原子接口;禁止拆成独立释放和转移调用。全部释放但子单仍有数量时,由仓库服务重新选择同仓普通库位,不足才落 A0。
- 测量提交按 `max(已确认称重图片数, ceil(实测体积 / 默认托盘容量))` 预留整票库位块;实际称重图片数与预留槽位数独立。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定块内最小空槽,禁止按列表下标推断。普通位不足时 Warehouse 可将剩余槽位放入共享 A0,不阻断测量提交。
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。
- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。
- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。
## 全量验收导航
@@ -23,6 +23,7 @@ public interface PendingReviewOrderClient {
@RequestParam(required = false) String inboundCode,
@RequestParam(required = false) String createdStart,
@RequestParam(required = false) String createdEnd,
@RequestParam(required = false) List<Long> excludedOrderIds,
@RequestParam long page,
@RequestParam long size);
@@ -30,35 +30,21 @@ public interface WarehousePalletLocationClient {
@org.springframework.web.bind.annotation.PathVariable("palletId") long palletId,
@RequestParam("warehouseId") long warehouseId);
@PostMapping("/pallet-locations/reservations")
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReserveRequest request);
@PostMapping("/pallet-locations/blocks/reservations")
ApiResponse<BlockResult> reserveBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockReserveRequest request);
@PostMapping("/pallet-locations/confirm")
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ConfirmRequest request);
@PostMapping("/pallet-locations/blocks/confirm")
ApiResponse<BlockResult> confirmBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockConfirmRequest request);
@PostMapping("/pallet-locations/release")
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
@PostMapping("/pallet-locations/blocks/transfer")
ApiResponse<BlockResult> transferBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockTransferRequest request);
@PostMapping("/pallet-locations/release-selected")
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
@PostMapping("/pallet-locations/transfer")
ApiResponse<List<TransferResult>> transfer(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody TransferRequest request);
@PostMapping("/pallet-locations/settlements")
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request);
@GetMapping("/pallet-locations/settlements/target")
ApiResponse<Long> settlementTarget(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("sourceBusinessId") long sourceBusinessId,
@RequestParam("idempotencyKey") String idempotencyKey);
@PostMapping("/pallet-locations/blocks/release")
ApiResponse<BlockResult> releaseBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockReleaseRequest request);
@GetMapping("/pallet-locations/occupied-resources")
ApiResponse<List<OccupiedResource>> occupiedResources(
@@ -67,44 +53,18 @@ public interface WarehousePalletLocationClient {
@RequestParam("sourceService") String sourceService,
@RequestParam("businessId") long businessId);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, List<PalletReference> pallets,
LocalDateTime expiresAt, boolean measurementCompleted) {}
record PalletReference(long weightResultId, long palletId) {}
record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId,
long operatorId, BigDecimal totalVolumeCbm, int packageCount) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId,
String idempotencyKey, long operatorId, String reason,
List<Long> locationIds) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, BigDecimal palletCapacityCbm,
long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
long locationId, String locationCode, String state) {}
record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo,
long palletId, long locationId, String locationCode,
int allocatedPackageCount) {}
record TransferItem(long locationId, int remainingPackageCount) {}
record TransferRequest(long tenantId, String sourceService, long sourceBusinessId,
long targetBusinessId, String targetInboundCode, String idempotencyKey,
long operatorId, List<TransferItem> items) {}
record TransferResult(long reservationId, String reservationNo, long occupancyId, int allocationNo,
long warehouseResourceTenantId, long warehouseId, long palletId,
String palletCode, BigDecimal palletCapacityCbm, long locationId,
String locationCode, BigDecimal allocatedVolumeCbm,
int remainingPackageCount, String state) {}
record SettlementTransferItem(long reservationId, int remainingPackageCount) {}
record SettlementRequest(long warehouseId, String sourceService, long sourceBusinessId,
long targetBusinessId, int sourcePackageCount, int targetPackageCount,
String targetInboundCode, String idempotencyKey, long operatorId,
String operatorName, String requestId, List<Long> releasedReservationIds,
List<SettlementTransferItem> remainingItems) {}
record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> transferred) {}
record BlockReserveRequest(long warehouseId, String sourceService, long businessId,
String inboundCode, int requiredSlotCount, String idempotencyKey,
long operatorId, LocalDateTime expiresAt) {}
record BlockConfirmRequest(long blockId, long businessId, String idempotencyKey,
long operatorId) {}
record BlockTransferRequest(long blockId, long sourceBusinessId, long targetBusinessId,
String targetInboundCode, String idempotencyKey, long operatorId) {}
record BlockReleaseRequest(long blockId, long businessId, String idempotencyKey,
long operatorId, String reason) {}
record BlockResource(long blockId, long reservationId, long locationId, String locationCode,
int slotNo, boolean sharedOverflow, String resourceStatus) {}
record BlockResult(long blockId, String blockStatus, List<BlockResource> resources) {}
record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId,
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
@@ -216,7 +216,8 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_WEIGHT_IMAGE_DELETE", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "删除称重图片及重量结果")
@Operation(summary = "删除称重图片", description = "删除重量结果和文件;已确认图片删除后重新汇总实际总毛重。")
@Operation(summary = "删除称重图片",
description = "先将图片绑定槽恢复为 RESERVED_EMPTY,再删除重量结果和文件;整票库位块继续归当前入仓码。")
@DeleteMapping("/weight-results/{resultId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<Void> deleteWeightResult(
@@ -38,4 +38,6 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/unpicked-items") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> removeUnpicked(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RemoveUnpickedRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.removeUnpicked(planId,r,u.userId(),u.username()));}
}
@@ -47,7 +47,8 @@ public final class InboundMeasurementResponses {
@Schema(description = "Order 服务中的最新客户申报,只用于和既有实测事实对比;不得回写测量、箱码或审核结果")
DeclarationSnapshot latestDeclaration,
List<Item> items,
List<WeightResult> weightResults
List<WeightResult> weightResults,
PalletLocationGuide palletLocationGuide
) {
}
@@ -129,10 +130,42 @@ public final class InboundMeasurementResponses {
LocalDateTime adjustedAt,
Long confirmedBy,
LocalDateTime confirmedAt,
Integer palletSequence,
Long blockId,
Integer slotNo,
Long reservationId,
Long locationId,
String locationCode,
Boolean sharedOverflow,
String slotStatus,
Integer version
) {
}
/** 服务端权威的整票上架引导;包含尚未绑定称重图片的预留空槽。 */
public record PalletLocationGuide(
Long blockId,
Integer reservedSlotCount,
Integer actualPalletCount,
Integer reservedEmptySlotCount,
List<PalletLocationSlot> slots
) {
}
public record PalletLocationSlot(
Integer slotNo,
Long reservationId,
Long locationId,
String locationCode,
Boolean sharedOverflow,
String slotStatus,
Long boundWeightResultId,
Integer palletSequence,
Long palletId,
String palletCode
) {
}
public record PalletSnapshot(
Long palletId,
String palletCode,
@@ -21,9 +21,11 @@ public record InboundOrderDetail(
this(summary, publicRemark, List.of(), palletLocations, files, internalNotes, statusHistory);
}
public record PalletLocationSummary(
Integer allocationNo, Long warehouseId, Long palletId, String palletCode,
Integer allocationNo, Long blockId, Integer slotNo, Long weightResultId,
Integer palletSequence, Long warehouseId, Long palletId, String palletCode,
BigDecimal palletCapacityCbm, BigDecimal allocatedVolumeCbm, Integer allocatedPackageCount,
Long locationId, String locationCode, String reservationNo, String allocationStatus) {
Long reservationId, Long locationId, String locationCode, Boolean sharedOverflow,
String slotStatus, String allocationStatus) {
}
public record FileSummary(
@@ -1,6 +1,5 @@
package com.baiyue.firstleg.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
@@ -20,31 +19,17 @@ public final class LoadingPickDtos {
public record PickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer pickedPackageCount,
@Pattern(regexp = "AUTO|MANUAL") String releaseMode,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
public PickRequest(Long warehouseId, Integer pickedPackageCount, List<Long> locationIds,
Integer version, String operationId) {
this(warehouseId, pickedPackageCount, null, locationIds, version, operationId);
}
}
) {}
/** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
public record PartialPickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer finalPickedPackageCount,
@Pattern(regexp = "AUTO|MANUAL") String releaseMode,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
public PartialPickRequest(Long warehouseId, Integer finalPickedPackageCount, List<Long> locationIds,
Integer version, String operationId) {
this(warehouseId, finalPickedPackageCount, null, locationIds, version, operationId);
}
}
) {}
/** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */
public record SkipRequest(
@@ -70,8 +55,6 @@ public final class LoadingPickDtos {
@NotBlank @Pattern(regexp = "PICK|PARTIAL|SKIP") String action,
@Min(1) Integer pickedPackageCount,
@Min(1) Integer finalPickedPackageCount,
@Pattern(regexp = "AUTO|MANUAL") String releaseMode,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@Size(max = 500) String reason
) {
@@ -73,6 +73,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record RemoveUnpickedRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotBlank @Size(max=128) String operationId) {}
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
@NotBlank @Size(max=128) String operationId) {}
@@ -19,6 +19,14 @@ public class InboundPalletAllocationEntity {
private Long tenantId;
private Long inboundId;
private Integer allocationNo;
/** 整票连续库位块标识;同一入库单的活动槽位共享该标识。 */
private Long blockId;
/** 连续块内从 1 开始的稳定槽位序号。 */
private Integer slotNo;
/** 称重图片对应的稳定重量结果;空槽允许为空。 */
private Long weightResultId;
/** 已绑定称重托盘在整票内的稳定展示顺序;空槽允许为空。 */
private Integer palletSequence;
private Long warehouseId;
private Long warehouseResourceTenantId;
private Long palletId;
@@ -29,9 +37,12 @@ public class InboundPalletAllocationEntity {
private Integer pickedPackageCount;
private Long locationId;
private String locationCode;
/** 普通连续位不足时由 Warehouse 分配到共享 A0。 */
private Boolean sharedOverflow;
/** 仓库资源唯一预占标识;A0 多托盘场景不能使用 locationId 作为唯一标识。 */
private Long reservationId;
private String reservationNo;
/** 槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED。 */
private String slotStatus;
private String allocationStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -23,10 +23,6 @@ public class LoadingPickItemEntity {
private Integer plannedPackageCount;
private Integer pickedPackageCount;
private String itemStatus;
/** AUTO 按比例释放,MANUAL 按指定库位释放。 */
private String releaseMode;
/** 人工选择的库位 ID,JSON 数组;空数组表示明确不释放。 */
private String releaseLocationIds;
@Version
private Integer version;
private LocalDateTime createdAt;
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -40,6 +41,7 @@ public class LoadingDeliveryService {
@Resource private LoadingCompletionGateService completionGate;
@Resource private InboundStatusHistoryMapper histories;
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource private FbaPricingService pricingService;
@Resource private ObjectMapper objectMapper;
@Transactional
@@ -64,6 +66,7 @@ public class LoadingDeliveryService {
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus()));
}
@@ -74,6 +77,7 @@ public class LoadingDeliveryService {
requireCabinetEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -116,6 +120,7 @@ public class LoadingDeliveryService {
CompletionTarget target,
boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) {
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -131,6 +136,7 @@ public class LoadingDeliveryService {
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -9,6 +9,41 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
@Select("""
SELECT * FROM fl_inbound_pallet_allocation
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND slot_status = 'RESERVED_EMPTY'
AND allocation_status IN ('RESERVED', 'CONFIRMED')
ORDER BY slot_no ASC
LIMIT 1 FOR UPDATE
""")
InboundPalletAllocationEntity selectNextEmptyForUpdate(
@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET weight_result_id=#{weightResultId}, pallet_sequence=#{palletSequence},
pallet_id=#{palletId}, pallet_code=#{palletCode},
pallet_capacity_cbm=#{palletCapacityCbm}, slot_status='BOUND',
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId}
AND slot_status='RESERVED_EMPTY' AND weight_result_id IS NULL
""")
int bindWeight(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("weightResultId") long weightResultId,
@Param("palletSequence") int palletSequence, @Param("palletId") long palletId,
@Param("palletCode") String palletCode,
@Param("palletCapacityCbm") java.math.BigDecimal palletCapacityCbm);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET weight_result_id=NULL, pallet_sequence=NULL, pallet_id=NULL, pallet_code=NULL,
pallet_capacity_cbm=NULL, slot_status='RESERVED_EMPTY', updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId}
AND weight_result_id=#{weightResultId} AND slot_status='BOUND'
""")
int unbindWeight(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("weightResultId") long weightResultId);
@Select("""
SELECT *
FROM fl_inbound_pallet_allocation
@@ -39,21 +74,29 @@ public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletA
int consume(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("count") int count);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET allocation_status = 'TRANSFERRED', updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND allocation_status = 'CONFIRMED' AND picked_package_count < allocated_package_count
@Select("""
SELECT COUNT(*) FROM fl_inbound_pallet_allocation
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
AND allocation_status='CONFIRMED'
""")
int markTransferred(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
int countBlock(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("blockId") long blockId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET allocation_status = 'RELEASED', updated_at = CURRENT_TIMESTAMP(3)
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND reservation_id = #{reservationId} AND allocation_status = 'CONFIRMED'
SET allocation_status='TRANSFERRED', slot_status='TRANSFERRED', updated_at=CURRENT_TIMESTAMP(3)
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
AND allocation_status='CONFIRMED'
""")
int markReleased(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("reservationId") long reservationId);
int markBlockTransferred(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("blockId") long blockId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET allocation_status='RELEASED', slot_status='RELEASED', updated_at=CURRENT_TIMESTAMP(3)
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
AND allocation_status='CONFIRMED'
""")
int markBlockReleased(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("blockId") long blockId);
}
@@ -171,7 +171,6 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
SET item_status = CASE WHEN picked_package_count + #{count} = planned_package_count
THEN 'PICKED' ELSE 'PICKING' END,
picked_package_count = picked_package_count + #{count},
release_mode = #{releaseMode}, release_location_ids = #{releaseLocationIds},
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
@@ -182,15 +181,12 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count,
@Param("releaseMode") String releaseMode,
@Param("releaseLocationIds") String releaseLocationIds,
@Param("version") int version,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET picked_package_count = #{finalCount}, item_status = 'PARTIAL',
release_mode = #{releaseMode}, release_location_ids = #{releaseLocationIds},
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
@@ -203,8 +199,6 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
AND #{finalCount} < planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount,
@Param("releaseMode") String releaseMode,
@Param("releaseLocationIds") String releaseLocationIds,
@Param("version") int version,
@Param("warehouseId") long warehouseId);
@@ -28,6 +28,27 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
""")
List<PlanColoadMemberRow> planColoadMembers(@Param("planId") long planId);
@Select("""
<script>
SELECT DISTINCT item.order_id
FROM fl_loading_plan_item item
JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
JOIN fl_inbound_order inbound
ON inbound.tenant_id=item.account_id AND inbound.id=item.inbound_id
WHERE item.deleted=0 AND plan.plan_status IN ('EDITING','LOADING')
AND item.order_id IS NOT NULL
AND inbound.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
<if test="query.warehouseId != null">AND inbound.warehouse_id=#{query.warehouseId}</if>
<if test="query.customerId != null">AND item.account_id=#{query.customerId}</if>
<if test="query.inboundCode != null and query.inboundCode.trim() != ''">
AND item.inbound_code=#{query.inboundCode}
</if>
</script>
""")
List<Long> occupiedOrderIds(@Param("query") InboundCandidateQuery query,
@Param("actualChannels") Set<String> actualChannels);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
@@ -134,6 +155,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED',
'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','WAIT_SPLIT','LOADING')
)
AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.account_id=o.tenant_id AND active_item.inbound_id=o.id
AND active_item.deleted=0
)
) eligible
LEFT JOIN fl_coload_group_member current_member
ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId
@@ -169,7 +169,7 @@ public class FbaPricingRepository {
public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getCustomerId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
@@ -212,9 +212,11 @@ public class InboundOrderRepository {
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo));
List<InboundOrderDetail.PalletLocationSummary> palletSummaries = palletAllocations.stream()
.map(item -> new InboundOrderDetail.PalletLocationSummary(
item.getAllocationNo(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(),
item.getAllocationNo(), item.getBlockId(), item.getSlotNo(), item.getWeightResultId(),
item.getPalletSequence(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(),
item.getPalletCapacityCbm(), item.getAllocatedVolumeCbm(), item.getAllocatedPackageCount(),
item.getLocationId(), item.getLocationCode(), item.getReservationNo(), item.getAllocationStatus()))
item.getReservationId(), item.getLocationId(), item.getLocationCode(), item.getSharedOverflow(),
item.getSlotStatus(), item.getAllocationStatus()))
.toList();
List<InboundOrderDetail.FileSummary> fileSummaries = files.selectList(
owned(InboundFileEntity::getTenantId, InboundFileEntity::getInboundId, tenantId, inboundId)
@@ -3,66 +3,114 @@ package com.baiyue.firstleg.repository;
import jakarta.annotation.Resource;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.client.WarehousePalletLocationClient.BatchResult;
import com.baiyue.firstleg.client.WarehousePalletLocationClient.BlockResult;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
@Repository
public class InboundPalletAllocationRepository {
@Resource private InboundPalletAllocationMapper mapper;
public List<InboundPalletAllocationEntity> list(long tenantId, long inboundId) {
return mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)
.orderByAsc(InboundPalletAllocationEntity::getSlotNo)
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo));
}
public InboundPalletAllocationEntity findByWeightResult(long tenantId, long inboundId,
long weightResultId) {
return mapper.selectOne(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)
.eq(InboundPalletAllocationEntity::getWeightResultId, weightResultId));
}
@Transactional
public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId,
int totalPackageCount, BatchResult result) {
Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId))
.stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getReservationId,
InboundPalletAllocationEntity::getReservationNo, (left, right) -> left));
public void replaceBlock(long tenantId, long inboundId, long warehouseId,
int totalPackageCount, BigDecimal totalVolumeCbm, BlockResult result) {
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now();
int allocationCount = result.allocations().size();
int allocationCount = result.resources().size();
int basePackageCount = allocationCount == 0 ? 0 : totalPackageCount / allocationCount;
int remainder = allocationCount == 0 ? 0 : totalPackageCount % allocationCount;
for (int index = 0; index < result.allocations().size(); index++) {
var source = result.allocations().get(index);
for (int index = 0; index < result.resources().size(); index++) {
var source = result.resources().get(index);
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
value.setAllocationNo(source.allocationNo());
value.setAllocationNo(source.slotNo());
value.setBlockId(source.blockId());
value.setSlotNo(source.slotNo());
value.setWarehouseId(warehouseId);
value.setWarehouseResourceTenantId(warehouseResourceTenantId);
value.setPalletId(source.palletId());
value.setPalletCode(source.palletCode());
value.setPalletCapacityCbm(source.palletCapacityCbm());
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
value.setWarehouseResourceTenantId(0L);
value.setAllocatedVolumeCbm(totalVolumeCbm.divide(BigDecimal.valueOf(allocationCount), 4,
java.math.RoundingMode.HALF_UP));
int allocatedPackages = basePackageCount + (index < remainder ? 1 : 0);
value.setAllocatedPackageCount(allocatedPackages);
value.setPickedPackageCount(0);
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
value.setSharedOverflow(source.sharedOverflow());
value.setReservationId(source.reservationId());
// 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。
value.setReservationNo(source.reservationNo() != null ? source.reservationNo()
: reservationNos.get(source.reservationId()));
value.setAllocationStatus(normalize(source.state()));
value.setSlotStatus("RELEASED".equals(source.resourceStatus()) ? "RELEASED" : "RESERVED_EMPTY");
value.setAllocationStatus(normalize(source.resourceStatus()));
value.setCreatedAt(now);
value.setUpdatedAt(now);
mapper.insert(value);
}
}
/** 已确认图片按 confirmedAt、weightResultId 顺序绑定块内下一个空槽,不依赖列表下标。 */
@Transactional
public void bindConfirmedWeights(long tenantId, long inboundId, List<InboundWeightResultEntity> weights) {
weights.stream().filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus()))
.sorted(java.util.Comparator.comparing(InboundWeightResultEntity::getConfirmedAt,
java.util.Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(InboundWeightResultEntity::getId))
.forEach(weight -> bindNextEmpty(tenantId, inboundId, weight));
}
@Transactional
public InboundPalletAllocationEntity bindNextEmpty(long tenantId, long inboundId,
InboundWeightResultEntity weight) {
InboundPalletAllocationEntity existing = findByWeightResult(tenantId, inboundId, weight.getId());
if (existing != null) return existing;
InboundPalletAllocationEntity empty = mapper.selectNextEmptyForUpdate(tenantId, inboundId);
if (empty == null) {
throw new IllegalStateException("整票预留库位块没有可绑定的空槽");
}
if (mapper.bindWeight(empty.getId(), tenantId, inboundId, weight.getId(), empty.getSlotNo(),
weight.getPalletId(), weight.getPalletCode(), weight.getPalletCapacityCbm()) != 1) {
throw new IllegalStateException("整票库位槽已被并发绑定");
}
empty.setWeightResultId(weight.getId());
empty.setPalletSequence(empty.getSlotNo());
empty.setPalletId(weight.getPalletId());
empty.setPalletCode(weight.getPalletCode());
empty.setPalletCapacityCbm(weight.getPalletCapacityCbm());
empty.setSlotStatus("BOUND");
return empty;
}
@Transactional
public void unbindWeight(long tenantId, long inboundId, long weightResultId) {
InboundPalletAllocationEntity existing = findByWeightResult(tenantId, inboundId, weightResultId);
if (existing != null && mapper.unbindWeight(existing.getId(), tenantId, inboundId, weightResultId) != 1) {
throw new IllegalStateException("整票库位槽解绑失败");
}
}
public List<InboundPalletAllocationEntity> lockConfirmed(long tenantId, long inboundId) {
return mapper.selectConfirmedForUpdate(tenantId, inboundId);
}
@@ -73,54 +121,17 @@ public class InboundPalletAllocationRepository {
}
}
/** 父单快照在仓库资源转移前标记为已转移,后续锁查询不会再将其当作可用资源。 */
public void markTransferred(long tenantId, long inboundId, List<InboundPalletAllocationEntity> allocations,
List<Long> releasedReservationIds) {
for (InboundPalletAllocationEntity allocation : allocations) {
if (releasedReservationIds.contains(allocation.getReservationId())) {
continue;
}
int allocated = allocation.getAllocatedPackageCount() == null ? 0 : allocation.getAllocatedPackageCount();
int picked = allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount();
if (allocated <= picked) {
continue;
}
if (allocation.getId() == null) {
throw new IllegalStateException("库位资源转移状态已被并发修改");
}
int changed = mapper.markTransferred(allocation.getId(), tenantId, inboundId);
if (changed != 1 && !isAllocationIdState(allocation.getId(), tenantId, inboundId, "TRANSFERRED")) {
throw new IllegalStateException("库位资源转移状态已被并发修改");
}
}
/** 余量子单存在时整票块转移,包含已经拣空和预留空槽。 */
public void markBlockTransferred(long tenantId, long inboundId, long blockId) {
int expected = mapper.countBlock(tenantId, inboundId, blockId);
int changed = mapper.markBlockTransferred(tenantId, inboundId, blockId);
if (expected < 1 || changed != expected) throw new IllegalStateException("整票库位块转移状态已被并发修改");
}
public void markReleased(long tenantId, long inboundId, List<Long> reservationIds) {
for (Long reservationId : reservationIds) {
if (reservationId == null) {
throw new IllegalStateException("库位释放状态已被并发修改");
}
int changed = mapper.markReleased(tenantId, inboundId, reservationId);
if (changed != 1 && !isReservationState(reservationId, tenantId, inboundId, "RELEASED")) {
throw new IllegalStateException("库位释放状态已被并发修改");
}
}
}
private boolean isAllocationIdState(long id, long tenantId, long inboundId, String status) {
InboundPalletAllocationEntity current = mapper.selectOne(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)
.eq(InboundPalletAllocationEntity::getId, id));
return current != null && status.equals(current.getAllocationStatus());
}
private boolean isReservationState(long reservationId, long tenantId, long inboundId, String status) {
InboundPalletAllocationEntity current = mapper.selectOne(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)
.eq(InboundPalletAllocationEntity::getReservationId, reservationId));
return current != null && status.equals(current.getAllocationStatus());
public void markBlockReleased(long tenantId, long inboundId, long blockId) {
int expected = mapper.countBlock(tenantId, inboundId, blockId);
int changed = mapper.markBlockReleased(tenantId, inboundId, blockId);
if (expected < 1 || changed != expected) throw new IllegalStateException("整票库位块释放状态已被并发修改");
}
/**
@@ -128,7 +139,7 @@ public class InboundPalletAllocationRepository {
* 仓库 transfer 是本地事务最后一个外部动作,成功后不再执行本地写入,避免跨服务重试时目标子单变化。
*/
@Transactional
public void copyRemainingToChild(long tenantId, long inboundId, List<InboundPalletAllocationEntity> parent) {
public void copyBlockToChild(long tenantId, long inboundId, List<InboundPalletAllocationEntity> parent) {
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
@@ -137,9 +148,6 @@ public class InboundPalletAllocationRepository {
int allocated = source.getAllocatedPackageCount() == null ? 0 : source.getAllocatedPackageCount();
int picked = source.getPickedPackageCount() == null ? 0 : source.getPickedPackageCount();
int remaining = allocated - picked;
if (remaining <= 0) {
continue;
}
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
@@ -155,7 +163,12 @@ public class InboundPalletAllocationRepository {
value.setLocationId(source.getLocationId());
value.setLocationCode(source.getLocationCode());
value.setReservationId(source.getReservationId());
value.setReservationNo(source.getReservationNo());
value.setBlockId(source.getBlockId());
value.setSlotNo(source.getSlotNo());
value.setWeightResultId(source.getWeightResultId());
value.setPalletSequence(source.getPalletSequence());
value.setSharedOverflow(source.getSharedOverflow());
value.setSlotStatus(source.getSlotStatus());
value.setAllocationStatus("CONFIRMED");
value.setCreatedAt(now);
value.setUpdatedAt(now);
@@ -135,14 +135,12 @@ public class LoadingPickRepository {
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
}
public int pick(long itemId, int count, String releaseMode, String releaseLocationIds,
int version, long warehouseId) {
return items.pick(itemId, count, releaseMode, releaseLocationIds, version, warehouseId);
public int pick(long itemId, int count, int version, long warehouseId) {
return items.pick(itemId, count, version, warehouseId);
}
public int partial(long itemId, int finalCount, String releaseMode, String releaseLocationIds,
int version, long warehouseId) {
return items.partial(itemId, finalCount, releaseMode, releaseLocationIds, version, warehouseId);
public int partial(long itemId, int finalCount, int version, long warehouseId) {
return items.partial(itemId, finalCount, version, warehouseId);
}
public int skip(long itemId, int version, long warehouseId) {
@@ -174,6 +172,12 @@ public class LoadingPickRepository {
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
}
public LoadingPickHistoryEntity historyForUpdate(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
.eq(LoadingPickHistoryEntity::getOperationId, operationId)
.last("FOR UPDATE"));
}
public void insert(LoadingPickHistoryEntity history) {
histories.insert(history);
}
@@ -215,6 +219,17 @@ public class LoadingPickRepository {
.eq(LoadingPickOperationEntity::getOperationId, operationId));
}
/**
* Current read used after the pick-task row lock has serialized concurrent commands.
* A normal repeatable-read lookup would keep the transaction's earlier snapshot and
* miss an operation committed while this request was waiting for that row lock.
*/
public LoadingPickOperationEntity operationForUpdate(String operationId) {
return operations.selectOne(new LambdaQueryWrapper<LoadingPickOperationEntity>()
.eq(LoadingPickOperationEntity::getOperationId, operationId)
.last("FOR UPDATE"));
}
public void insert(LoadingPickOperationEntity operation) {
operations.insert(operation);
}
@@ -59,6 +59,9 @@ public class SmartLoadingRepository {
public List<PlanColoadMemberRow> planColoadMembers(long planId) {
return candidates.planColoadMembers(planId);
}
public List<Long> occupiedOrderIds(InboundCandidateQuery query, Set<String> actualChannels) {
return candidates.occupiedOrderIds(query, actualChannels);
}
public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) {
return candidates.candidate(planId, warehouseId, inboundId, detailId);
}
@@ -35,6 +35,8 @@ import java.util.List;
@Service
public class FbaPricingService {
private static final int MONEY_SCALE = 2;
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
@Resource
private FbaPricingRepository repository;
@Resource
@@ -91,16 +93,25 @@ public class FbaPricingService {
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
if ("OUTER".equalsIgnoreCase(volumeSource) && measurement == null) {
throw conflict("外箱规测量批次不存在,不能生成对客费用");
boolean actualMeasurementSource = "ACTUAL".equalsIgnoreCase(weightSource)
|| "ACTUAL".equalsIgnoreCase(volumeSource);
boolean outerMeasurementSource = "OUTER".equalsIgnoreCase(weightSource)
|| "OUTER".equalsIgnoreCase(volumeSource);
if ((actualMeasurementSource || outerMeasurementSource) && measurement == null) {
throw conflict(actualMeasurementSource
? "实际测量数据不存在,不能生成对客费用"
: "外箱规测量批次不存在,不能生成对客费用");
}
if ("OUTER".equalsIgnoreCase(volumeSource)) {
var outerItems = measurementRepository.listItems(tenantId, measurement.getId());
boolean incomplete = outerItems.isEmpty() || outerItems.stream().anyMatch(item ->
if (actualMeasurementSource || outerMeasurementSource) {
var measurementItems = measurementRepository.listItems(tenantId, measurement.getId());
if (actualMeasurementSource && measurementItems.isEmpty()) {
throw conflict("实际测量明细不存在,不能生成对客费用");
}
boolean incomplete = outerMeasurementSource && (measurementItems.isEmpty() || measurementItems.stream().anyMatch(item ->
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|| !StringUtils.hasText(item.getCalculationRuleVersion()));
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
@@ -204,6 +215,21 @@ public class FbaPricingService {
return response(tenantId, fee.getId(), null);
}
/** 客户未操作时,装柜完成以系统身份确认最新一版已发送费用。 */
@Transactional
public void autoConfirmAfterLoading(long tenantId, long inboundId) {
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
if (current == null || !"SENT".equals(current.getFeeStatus())
|| current.getCustomerConfirmedAt() != null) {
return;
}
LocalDateTime now = LocalDateTime.now(clock);
if (repository.confirmFeeByCustomer(tenantId, current.getId(), current.getFeeVersion(),
AUTOMATIC_CONFIRM_OPERATOR_ID, AUTOMATIC_CONFIRM_OPERATOR_NAME, now) != 1) {
throw conflict("装柜完成自动确认费用失败,请重试");
}
}
@Transactional
public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName,
long inboundId, int expectedFeeVersion,
@@ -339,9 +365,6 @@ public class FbaPricingService {
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持自动计费");
}
if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) {
throw conflict("入库单缺少订单客户归属,不能生成对客费用");
}
if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) {
throw conflict("入库单缺少起运仓或渠道匹配维度");
}
@@ -419,7 +442,7 @@ public class FbaPricingService {
fee.setChargeRuleId(null); fee.setChargeRuleVersion(null);
fee.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
fee.setPriceBookVersionId(estimate.priceBookVersionId());
fee.setRuleCode(null); fee.setCustomerId(inbound.getCustomerId());
fee.setRuleCode(null); fee.setCustomerId(tenantId);
fee.setCustomerName(inbound.getCustomerName()); fee.setBusinessType(inbound.getBusinessType());
fee.setTaxMode(inbound.getTaxMode()); fee.setPricingTaxMode(inbound.getPricingTaxMode());
fee.setRoundingScale(estimate.roundingScale()); fee.setRoundingMode(estimate.roundingMode());
@@ -100,7 +100,7 @@ public class InboundFeeDetailService {
detail.setInboundCode(inbound.getInboundCode());
detail.setDirection("RECEIVABLE");
detail.setBusinessDate(now.toLocalDate());
detail.setCustomerId(inbound.getCustomerId());
detail.setCustomerId(tenantId);
detail.setCustomerName(inbound.getCustomerName());
detail.setInvoiceTitle(inbound.getCompanyName());
detail.setFeeCategorySnapshot(feeItem.getCategory());
@@ -186,7 +186,7 @@ public class InboundFeeDetailService {
detail.setSourceType("FBA_PRICING"); detail.setSourceFeeId(fee.getId());
detail.setSourceFeeVersion(fee.getFeeVersion()); detail.setSourceFeeNo(fee.getFeeNo());
detail.setInboundCode(inbound.getInboundCode()); detail.setDirection("RECEIVABLE");
detail.setBusinessDate(now.toLocalDate()); detail.setCustomerId(inbound.getCustomerId());
detail.setBusinessDate(now.toLocalDate()); detail.setCustomerId(tenantId);
detail.setCustomerName(inbound.getCustomerName()); detail.setInvoiceTitle(inbound.getCompanyName());
detail.setFeeCategorySnapshot(feeItem.getCategory()); detail.setTradeScopeSnapshot(feeItem.getTradeScope());
detail.setFeeTypeNameSnapshot(feeItem.getFeeTypeName()); detail.setChannelCode(inbound.getChannelCode());
@@ -19,6 +19,7 @@ import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -30,6 +31,7 @@ import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
@@ -63,6 +65,8 @@ public class InboundMeasurementService {
private MeasurementImageClient imageClient;
@Resource
private WarehousePalletLocationClient warehousePallets;
@Resource
private InboundPalletAllocationRepository palletAllocations;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -82,9 +86,18 @@ public class InboundMeasurementService {
List<InboundMeasurementItemEntity> items = measurement == null
? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inbound.getId());
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots =
palletAllocations.list(tenantId, inbound.getId());
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight =
allocationSlots.stream()
.filter(item -> item.getWeightResultId() != null)
.collect(java.util.stream.Collectors.toMap(
com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId,
java.util.function.Function.identity(), (left, right) -> left));
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, review, latestDeclaration);
return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight,
review, latestDeclaration);
}
public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
@@ -311,6 +324,9 @@ public class InboundMeasurementService {
weight.setVersion(confirmedVersion);
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
grossWeightKg, netWeightKg, null, operatorId, operatorName);
if (!palletAllocations.list(tenantId, inboundId).isEmpty()) {
palletAllocations.bindNextEmpty(tenantId, inboundId, weight);
}
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
@@ -322,6 +338,7 @@ public class InboundMeasurementService {
requireWeightEditableInbound(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(version, weight.getVersion());
palletAllocations.unbindWeight(tenantId, inboundId, resultId);
if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) {
throw concurrentModification();
}
@@ -433,6 +450,8 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.Detail toDetail(
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots,
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight,
MeasureReviewRecordEntity review,
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) {
return new InboundMeasurementResponses.Detail(
@@ -460,7 +479,32 @@ public class InboundMeasurementService {
measurement != null && StringUtils.hasText(measurement.getActualGoodsName())
? measurement.getActualGoodsName() : latestDeclaration.goodsName(),
latestDeclaration,
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
items.stream().map(this::toItem).toList(), weights.stream()
.map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(),
toPalletLocationGuide(allocationSlots));
}
private static InboundMeasurementResponses.PalletLocationGuide toPalletLocationGuide(
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> slots) {
if (slots.isEmpty()) return null;
Long blockId = slots.get(0).getBlockId();
if (blockId == null || slots.stream().anyMatch(slot -> !java.util.Objects.equals(blockId, slot.getBlockId()))) {
throw new BizException(ResultCode.CONFLICT, "入库单整票库位块快照不一致");
}
List<InboundMeasurementResponses.PalletLocationSlot> ordered = slots.stream()
.sorted(java.util.Comparator.comparing(
com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getSlotNo,
java.util.Comparator.nullsLast(Integer::compareTo)))
.map(slot -> new InboundMeasurementResponses.PalletLocationSlot(
slot.getSlotNo(), slot.getReservationId(), slot.getLocationId(), slot.getLocationCode(),
slot.getSharedOverflow(), slot.getSlotStatus(), slot.getWeightResultId(),
slot.getPalletSequence(), slot.getPalletId(), slot.getPalletCode()))
.toList();
int actualPalletCount = (int) slots.stream().filter(slot -> slot.getWeightResultId() != null).count();
int reservedEmptySlotCount = (int) slots.stream()
.filter(slot -> "RESERVED_EMPTY".equals(slot.getSlotStatus())).count();
return new InboundMeasurementResponses.PalletLocationGuide(
blockId, ordered.size(), actualPalletCount, reservedEmptySlotCount, ordered);
}
/**
@@ -576,6 +620,13 @@ public class InboundMeasurementService {
}
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
return toWeight(weight, weight.getId() == null ? null
: palletAllocations.findByWeightResult(weight.getTenantId(), weight.getInboundId(), weight.getId()));
}
private InboundMeasurementResponses.WeightResult toWeight(
InboundWeightResultEntity weight,
com.baiyue.firstleg.entity.InboundPalletAllocationEntity allocation) {
return new InboundMeasurementResponses.WeightResult(
weight.getId(), weight.getFileId(), weight.getPalletId(), weight.getPalletCode(),
weight.getPalletType(), weight.getPalletCapacityCbm(), weight.getPalletTareWeightKg(),
@@ -585,7 +636,15 @@ public class InboundMeasurementService {
weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(),
weight.getNetWeightKg(),
weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(),
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion());
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(),
allocation == null ? null : allocation.getPalletSequence(),
allocation == null ? null : allocation.getBlockId(),
allocation == null ? null : allocation.getSlotNo(),
allocation == null ? null : allocation.getReservationId(),
allocation == null ? null : allocation.getLocationId(),
allocation == null ? null : allocation.getLocationCode(),
allocation == null ? null : allocation.getSharedOverflow(),
allocation == null ? null : allocation.getSlotStatus(), weight.getVersion());
}
private PageResult<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshotPage(
@@ -253,6 +253,8 @@ public class LoadingPickService {
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
if (existing == null) {
if (request.expectedAssignmentVersion() != 0
@@ -282,6 +284,8 @@ public class LoadingPickService {
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
int changed = existing == null
? request.expectedAssignmentVersion() == 0
@@ -296,10 +300,9 @@ public class LoadingPickService {
@Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
ReleaseSelection release = releaseSelection(request.releaseMode(), request.locationIds());
String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount() + "|" + release.mode() + "|" + release.json());
+ "|" + request.pickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
@@ -310,12 +313,17 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireNoTaskOperationForUpdate(operationId);
replay = repository.historyForUpdate(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
if (repository.pick(itemId, request.pickedPackageCount(), release.mode(), release.json(),
request.version(), request.warehouseId()) != 1) {
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
@@ -336,10 +344,9 @@ public class LoadingPickService {
@Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) {
ReleaseSelection release = releaseSelection(request.releaseMode(), request.locationIds());
String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount() + "|" + release.mode() + "|" + release.json());
+ "|" + request.finalPickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
@@ -347,9 +354,15 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireNoTaskOperationForUpdate(operationId);
replay = repository.historyForUpdate(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), release.mode(), release.json(),
request.version(), request.warehouseId()) != 1) {
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
@@ -372,6 +385,12 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireNoTaskOperationForUpdate(operationId);
replay = repository.historyForUpdate(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
@@ -398,7 +417,7 @@ public class LoadingPickService {
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredTaskForUpdate(taskId);
replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能重复提交整批拣货");
@@ -970,10 +989,8 @@ public class LoadingPickService {
throw conflict("PDA明细版本已变化,请刷新后重试");
}
String action = requestItem.action().trim().toUpperCase();
ReleaseSelection release = null;
if ("PICK".equals(action)) {
requireNoUnexpectedBatchFields(requestItem, false, true);
release = releaseSelection(requestItem.releaseMode(), requestItem.locationIds());
int current = count(item.getPickedPackageCount());
int planned = count(item.getPlannedPackageCount());
if (requestItem.pickedPackageCount() == null
@@ -982,7 +999,6 @@ public class LoadingPickService {
}
} else if ("PARTIAL".equals(action)) {
requireNoUnexpectedBatchFields(requestItem, true, false);
release = releaseSelection(requestItem.releaseMode(), requestItem.locationIds());
int current = count(item.getPickedPackageCount());
int planned = count(item.getPlannedPackageCount());
if (requestItem.finalPickedPackageCount() == null
@@ -991,10 +1007,8 @@ public class LoadingPickService {
throw conflict("PARTIAL最终箱数必须大于已拣且小于计划箱数");
}
} else if ("SKIP".equals(action)) {
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null
|| StringUtils.hasText(requestItem.releaseMode())
|| requestItem.locationIds() != null && !requestItem.locationIds().isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "SKIP不能携带拣货数量或释放库位");
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null) {
throw new BizException(ResultCode.BAD_REQUEST, "SKIP不能携带拣货数量");
}
if (!StringUtils.hasText(requestItem.reason())) {
throw new BizException(ResultCode.BAD_REQUEST, "SKIP必须填写异常原因");
@@ -1007,7 +1021,7 @@ public class LoadingPickService {
} else {
throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL或SKIP");
}
result.add(new BatchLine(item, requestItem, action, release, request.warehouseId()));
result.add(new BatchLine(item, requestItem, action, request.warehouseId()));
}
result.sort(Comparator.comparing(line -> line.item().getId()));
return result;
@@ -1110,12 +1124,10 @@ public class LoadingPickService {
int pickedCount;
if ("PICK".equals(line.action())) {
pickedCount = request.pickedPackageCount();
changed = repository.pick(item.getId(), pickedCount, line.release().mode(),
line.release().json(), request.version(), line.warehouseId());
changed = repository.pick(item.getId(), pickedCount, request.version(), line.warehouseId());
} else if ("PARTIAL".equals(line.action())) {
pickedCount = request.finalPickedPackageCount();
changed = repository.partial(item.getId(), pickedCount, line.release().mode(),
line.release().json(), request.version(), line.warehouseId());
changed = repository.partial(item.getId(), pickedCount, request.version(), line.warehouseId());
} else {
pickedCount = 0;
changed = repository.skip(item.getId(), request.version(), line.warehouseId());
@@ -1125,8 +1137,7 @@ public class LoadingPickService {
requireNoTaskOperation(itemOperationId);
if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交");
String itemHash = hash(line.action() + '|' + item.getId() + '|' + request.version()
+ '|' + pickedCount + '|' + (line.release() == null ? "" : line.release().mode())
+ '|' + (line.release() == null ? "" : line.release().json()) + '|' + trim(request.reason()));
+ '|' + pickedCount + '|' + trim(request.reason()));
insertHistory(taskId, item.getId(), itemOperationId, itemHash, line.action(), pickedCount,
trim(request.reason()), operatorId, operatorName);
}
@@ -1170,6 +1181,18 @@ public class LoadingPickService {
private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId) {
if (repository.history(operationId) != null) throw conflict("operationId已用于明细拣货操作");
LoadingPickOperationEntity replay = repository.operation(operationId);
return replayTaskOperation(taskId, operationId, requestHash, operatorId, replay);
}
private Task replayTaskOperationForUpdate(long taskId, String operationId, String requestHash,
long operatorId) {
if (repository.historyForUpdate(operationId) != null) throw conflict("operationId已用于明细拣货操作");
LoadingPickOperationEntity replay = repository.operationForUpdate(operationId);
return replayTaskOperation(taskId, operationId, requestHash, operatorId, replay);
}
private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId,
LoadingPickOperationEntity replay) {
if (replay == null) return null;
if (!Long.valueOf(taskId).equals(replay.getPickTaskId())
|| !Long.valueOf(operatorId).equals(replay.getOperatorId())
@@ -1195,6 +1218,10 @@ public class LoadingPickService {
if (repository.operation(operationId) != null) throw conflict("operationId已用于PDA任务操作");
}
private void requireNoTaskOperationForUpdate(String operationId) {
if (repository.operationForUpdate(operationId) != null) throw conflict("operationId已用于PDA任务操作");
}
private static String operation(String value) {
String result = trim(value);
if (!StringUtils.hasText(result)) throw new BizException(ResultCode.BAD_REQUEST, "operationId不能为空");
@@ -1250,35 +1277,14 @@ public class LoadingPickService {
.forEach(item -> {
canonical.append('|').append(item.itemId()).append('|').append(trim(item.action()))
.append('|').append(item.pickedPackageCount()).append('|')
.append(item.finalPickedPackageCount()).append('|').append(trim(item.releaseMode()))
.append('|').append(item.version()).append('|').append(trim(item.reason())).append('|');
List<Long> locations = item.locationIds() == null ? List.of()
: item.locationIds().stream().sorted().toList();
canonical.append(locations);
.append(item.finalPickedPackageCount()).append('|')
.append(item.version()).append('|').append(trim(item.reason()));
});
return hash(canonical.toString());
}
private static ReleaseSelection releaseSelection(String requestedMode, List<Long> requestedLocationIds) {
String mode = requestedMode == null || requestedMode.isBlank()
? (requestedLocationIds == null ? "AUTO" : "MANUAL") : requestedMode.trim().toUpperCase();
if (!"AUTO".equals(mode) && !"MANUAL".equals(mode)) {
throw new BizException(ResultCode.BAD_REQUEST, "releaseMode只能是AUTO或MANUAL");
}
List<Long> ids = requestedLocationIds == null ? List.of() : requestedLocationIds.stream().sorted().toList();
if (new HashSet<>(ids).size() != ids.size()) {
throw new BizException(ResultCode.BAD_REQUEST, "释放库位不能重复");
}
if ("AUTO".equals(mode) && !ids.isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "AUTO模式不能指定释放库位");
}
String json = ids.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(",", "[", "]"));
return new ReleaseSelection(mode, json);
}
private record BatchLine(LoadingPickItemEntity item, BatchItemRequest request, String action,
ReleaseSelection release, long warehouseId) {}
private record ReleaseSelection(String mode, String json) {}
long warehouseId) {}
private static String trim(String value) {
return value == null ? null : value.trim();
@@ -62,7 +62,7 @@ public class MeasurementBatchService {
}
validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId()), inbound.getCustomerId());
command, dimensionWeighting.load(command.tenantId()));
InboundMeasurementEntity measurement;
if (command.measurementId() == null) {
@@ -176,8 +176,7 @@ public class MeasurementBatchService {
}
private List<InboundMeasurementItemEntity> calculateItems(
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules,
Long customerId) {
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) {
Set<String> itemNumbers = new HashSet<>();
return command.items().stream().map(source -> {
if (!itemNumbers.add(source.itemNo())) {
@@ -199,8 +198,8 @@ public class MeasurementBatchService {
baseLength, baseWidth, baseHeight);
String requestedMode = normalizeMode(source.coefficientMode());
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode)
? requireCoefficientRule(command.tenantId(), customerId, source.chargeRuleId())
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId(), customerId) : null;
? requireCoefficientRule(command.tenantId(), source.chargeRuleId())
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId()) : null;
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
boolean useDimensionRule = "ADD".equals(coefficientMode);
BigDecimal addition = weighted.edgeAdditionCm() == null
@@ -293,15 +292,13 @@ public class MeasurementBatchService {
return normalized;
}
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId, Long customerId) {
if (customerId == null || customerId <= 0) return null;
return pricingRepository.findDefaultRule(tenantId, customerId);
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId) {
return pricingRepository.findDefaultRule(tenantId, tenantId);
}
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long customerId, Long ruleId) {
Long effectiveCustomerId = tenantId;
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) {
CustomerChargeRuleEntity rule = ruleId == null
? pricingRepository.findDefaultRule(tenantId, effectiveCustomerId)
? pricingRepository.findDefaultRule(tenantId, tenantId)
: pricingRepository.findRule(tenantId, ruleId);
if (rule == null || !"ACTIVE".equals(rule.getStatus())
|| !java.util.Objects.equals(rule.getTenantId(), tenantId)) {
@@ -41,30 +41,33 @@ public class MeasurementLocationOccupationService {
int measurementVersion) {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
long warehouseId = requireWarehouse(order);
long resourceTenantId = PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID;
BigDecimal volume = requireVolume(order);
var palletReferences = confirmedPalletReferences(tenantId, inboundId);
// 补偿释放后的预占记录需要保留审计,失败重试必须使用新的物理预占键;
// 若仍存在活动预占,仓储侧会按业务单据复用,不会创建新的资源占用。
String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion
var confirmedWeights = confirmedWeights(tenantId, inboundId);
int requiredSlotCount = requiredSlotCount(volume, confirmedWeights.size());
// Warehouse retains released blocks for audit. A compensated attempt therefore needs a new
// reservation key, otherwise a retry would replay the released block instead of reserving again.
String key = "measure-block-" + inboundId + "-" + measurementId + "-" + measurementVersion
+ "-" + UUID.randomUUID();
WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve(
internalToken, new WarehousePalletLocationClient.ReserveRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume,
palletReferences,
LocalDateTime.now().plus(properties.getReservationTtl()), true))),
WarehousePalletLocationClient.BlockResult reserved = requireSuccess(call(() -> warehouse.reserveBlock(
internalToken, new WarehousePalletLocationClient.BlockReserveRequest(warehouseId,
SOURCE_SERVICE, inboundId, order.getInboundCode(), requiredSlotCount, key, operatorId,
LocalDateTime.now().plus(properties.getReservationTtl())))),
"测量完成后库位预占失败");
int packageCount = requirePackageCount(order);
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, reserved);
requireBlockResult(reserved, requiredSlotCount, "RESERVED");
allocations.replaceBlock(tenantId, inboundId, warehouseId, packageCount, volume, reserved);
allocations.bindConfirmedWeights(tenantId, inboundId, confirmedWeights);
try {
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, operatorId, volume, packageCount))), "测量完成后库位占用失败");
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, occupied);
WarehousePalletLocationClient.BlockResult occupied = requireSuccess(call(() -> warehouse.confirmBlock(
internalToken, new WarehousePalletLocationClient.BlockConfirmRequest(reserved.blockId(), inboundId,
key + "-confirm", operatorId))), "测量完成后库位占用失败");
requireBlockResult(occupied, requiredSlotCount, "OCCUPIED");
allocations.replaceBlock(tenantId, inboundId, warehouseId, packageCount, volume, occupied);
allocations.bindConfirmedWeights(tenantId, inboundId, confirmedWeights);
} catch (RuntimeException failure) {
try {
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
resourceTenantId, SOURCE_SERVICE, inboundId, operatorId, "测量完成库位占用失败"));
warehouse.releaseBlock(internalToken, new WarehousePalletLocationClient.BlockReleaseRequest(
reserved.blockId(), inboundId, key + "-compensate", operatorId, "MANUAL_ADJUSTMENT"));
} catch (RuntimeException releaseFailure) {
failure.addSuppressed(releaseFailure);
}
@@ -99,9 +102,9 @@ public class MeasurementLocationOccupationService {
return order.getTotalPackageCount();
}
private java.util.List<WarehousePalletLocationClient.PalletReference> confirmedPalletReferences(
private java.util.List<InboundWeightResultEntity> confirmedWeights(
long tenantId, long inboundId) {
java.util.List<WarehousePalletLocationClient.PalletReference> references = new java.util.ArrayList<>();
java.util.List<InboundWeightResultEntity> references = new java.util.ArrayList<>();
for (InboundWeightResultEntity weight : measurements.listWeights(tenantId, inboundId)) {
if (!"CONFIRMED".equals(weight.getRecognitionStatus())) {
continue;
@@ -118,7 +121,7 @@ public class MeasurementLocationOccupationService {
if (weight.getId() == null || weight.getId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少结果标识,无法分配库位");
}
references.add(new WarehousePalletLocationClient.PalletReference(weight.getId(), weight.getPalletId()));
references.add(weight);
}
if (references.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "至少需要一张已确认的称重图片才能提交测量");
@@ -129,6 +132,38 @@ public class MeasurementLocationOccupationService {
return references;
}
private int requiredSlotCount(BigDecimal volume, int actualPalletCount) {
BigDecimal capacity = properties.getDefaultPalletCapacityCbm();
if (capacity == null || capacity.signum() <= 0) {
throw new BizException(ResultCode.CONFLICT, "默认托盘容量配置无效,无法计算整票预留库位数");
}
int byVolume;
try {
byVolume = volume.divide(capacity, 0, java.math.RoundingMode.CEILING).intValueExact();
} catch (ArithmeticException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "整票预留库位数超过系统上限");
}
int required = Math.max(actualPalletCount, byVolume);
if (required < 1 || required > 200) {
throw new BizException(ResultCode.VALIDATION_FAILED, "整票预留库位数必须在1到200之间");
}
return required;
}
private static void requireBlockResult(WarehousePalletLocationClient.BlockResult result,
int requiredSlotCount, String expectedStatus) {
if (result.blockId() <= 0 || !expectedStatus.equals(result.blockStatus())
|| result.resources() == null || result.resources().size() != requiredSlotCount
|| result.resources().stream().anyMatch(resource -> resource.blockId() != result.blockId())) {
throw new BizException(ResultCode.CONFLICT, "Warehouse返回的整票库位块不完整");
}
java.util.List<Integer> slotNos = result.resources().stream()
.map(WarehousePalletLocationClient.BlockResource::slotNo).sorted().toList();
if (!slotNos.equals(java.util.stream.IntStream.rangeClosed(1, requiredSlotCount).boxed().toList())) {
throw new BizException(ResultCode.CONFLICT, "Warehouse返回的整票库位槽序不连续");
}
}
private static <T> ApiResponse<T> call(RemoteCall<T> call) {
try { return call.invoke(); }
catch (FeignException exception) {
@@ -153,18 +153,12 @@ public class PdaPickSettlementService {
confirmedAllocations.sort(Comparator.comparing(InboundPalletAllocationEntity::getAllocationNo,
Comparator.nullsLast(Integer::compareTo)));
consumePickedAllocations(parent.tenantId(), parentInboundId, confirmedAllocations, pickedPackageCount);
List<Long> releasedReservationIds = releasedReservationIds(confirmedAllocations);
List<WarehousePalletLocationClient.SettlementTransferItem> transferItems =
remainingTransferItems(confirmedAllocations);
int allocationRemaining = transferItems.stream()
.mapToInt(WarehousePalletLocationClient.SettlementTransferItem::remainingPackageCount).sum();
long blockId = blockId(confirmedAllocations);
int allocationRemaining = confirmedAllocations.stream().mapToInt(PdaPickSettlementService::remaining).sum();
if (allocationRemaining != childPackageCount) {
throw conflict("父入库单库位剩余箱数与未装柜箱数不一致");
}
long warehouseId = warehouseId(confirmedAllocations);
if (childPackageCount == 0 && (!transferItems.isEmpty() || releasedReservationIds.isEmpty())) {
throw conflict("全量拣货后库位资源结算不完整");
}
warehouseId(confirmedAllocations);
List<MeasurementItemRow> measurements = splits.lockLatestApprovedItemsForInbound(
parent.tenantId(), parentInboundId);
@@ -193,21 +187,13 @@ public class PdaPickSettlementService {
throw conflict("主入库单测量明细剩余箱数与未装柜箱数不一致");
}
if (childPackageCount > 0 && transferItems.isEmpty()) {
throw conflict("父入库单可转移库位剩余箱数与未装柜箱数不一致");
}
if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在");
Map<Long, Integer> orderQuantities = dropshipWholePallet
? new LinkedHashMap<>(pickedByDetail) : measurementQuantities(measurementLines);
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, orderQuantities,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId,
"pda-task-" + taskId, operatorId, operatorName);
String settlementKey = "PDA-SETTLE-" + taskId + '-' + parentInboundId;
long remainderChildId = childPackageCount > 0 ? existingSettlementTarget(parentInboundId, settlementKey) : 0L;
if (childPackageCount > 0 && remainderChildId == 0L) {
remainderChildId = IdWorker.getId();
}
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L;
long batchId = IdWorker.getId();
String idempotencyKey = "pda-task-" + taskId;
int rootVersion = root.version() == null ? 0 : root.version();
@@ -278,15 +264,14 @@ public class PdaPickSettlementService {
"PDA拣货后来源单承接实装并生成余量子入库单"), "拆分审计");
if (remainderChildId != 0L) {
allocations.copyRemainingToChild(parent.tenantId(), remainderChildId, confirmedAllocations);
allocations.copyBlockToChild(parent.tenantId(), remainderChildId, confirmedAllocations);
allocations.markBlockTransferred(parent.tenantId(), parentInboundId, blockId);
} else {
allocations.markBlockReleased(parent.tenantId(), parentInboundId, blockId);
}
allocations.markTransferred(parent.tenantId(), parentInboundId, confirmedAllocations,
releasedReservationIds);
allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds);
settleWarehouseResources(parentInboundId, remainderChildId,
settleWarehouseBlock(parentInboundId, remainderChildId,
remainderChildCode == null ? root.inboundCode() : remainderChildCode,
taskId, operatorId, operatorName,
warehouseId, parentAvailable, childPackageCount, releasedReservationIds, transferItems);
taskId, operatorId, blockId);
return true;
}
@@ -433,28 +418,17 @@ public class PdaPickSettlementService {
}
}
private static List<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
.distinct().sorted().toList();
}
private static List<WarehousePalletLocationClient.SettlementTransferItem> remainingTransferItems(
List<InboundPalletAllocationEntity> allocations) {
return allocations.stream().filter(item -> remaining(item) > 0)
.map(item -> new WarehousePalletLocationClient.SettlementTransferItem(reservationId(item), remaining(item)))
.toList();
}
private static int remaining(InboundPalletAllocationEntity allocation) {
return count(allocation.getAllocatedPackageCount(), "库位已分配箱数")
- (allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount());
}
private static long reservationId(InboundPalletAllocationEntity allocation) {
if (allocation.getReservationId() == null || allocation.getReservationId() <= 0) {
throw conflict("库位资源缺少唯一reservationId,请先重新确认托盘库位分配");
private static long blockId(List<InboundPalletAllocationEntity> allocations) {
if (allocations.isEmpty() || allocations.get(0).getBlockId() == null || allocations.get(0).getBlockId() <= 0
|| allocations.stream().anyMatch(item -> !Objects.equals(allocations.get(0).getBlockId(), item.getBlockId()))) {
throw conflict("PDA结算要求父入库单资源属于同一整票库位块");
}
return allocation.getReservationId();
return allocations.get(0).getBlockId();
}
private static long warehouseId(List<InboundPalletAllocationEntity> allocations) {
@@ -465,36 +439,33 @@ public class PdaPickSettlementService {
return allocations.get(0).getWarehouseId();
}
private void settleWarehouseResources(long parentInboundId, long childInboundId, String childCode,
long taskId, long operatorId, String operatorName, long warehouseId,
int sourcePackageCount, int targetPackageCount,
List<Long> releasedReservationIds,
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
ApiResponse<WarehousePalletLocationClient.SettlementResult> response;
private void settleWarehouseBlock(long parentInboundId, long childInboundId, String childCode,
long taskId, long operatorId, long blockId) {
ApiResponse<WarehousePalletLocationClient.BlockResult> response;
String key = "PDA-BLOCK-" + taskId + '-' + parentInboundId;
try {
response = warehouse.settle(internalToken, new WarehousePalletLocationClient.SettlementRequest(
warehouseId, SOURCE_SERVICE, parentInboundId, childInboundId,
sourcePackageCount, targetPackageCount, childCode, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
releasedReservationIds, remainingItems));
response = childInboundId > 0
? warehouse.transferBlock(internalToken, new WarehousePalletLocationClient.BlockTransferRequest(
blockId, parentInboundId, childInboundId, childCode, key, operatorId))
: warehouse.releaseBlock(internalToken, new WarehousePalletLocationClient.BlockReleaseRequest(
blockId, parentInboundId, key, operatorId, "ALL_PACKAGES_PICKED"));
} catch (FeignException exception) {
log.warn("PDA warehouse settlement transport failure taskId={} sourceInboundId={} operationId={} "
+ "httpStatus={} releasedReservationIds={} remainingReservations={}",
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
exception.status(), releasedReservationIds, settlementTransferFacts(remainingItems));
log.warn("PDA warehouse block settlement transport failure taskId={} sourceInboundId={} "
+ "blockId={} operationId={} httpStatus={}",
taskId, parentInboundId, blockId, key, exception.status());
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源");
}
if (response == null || !response.successful() || response.data() == null
|| response.data().released() == null || response.data().transferred() == null
|| !sameReleasedReservations(releasedReservationIds, response.data().released())
|| !sameTransferredResources(remainingItems, response.data().transferred())) {
log.warn("PDA warehouse settlement rejected taskId={} sourceInboundId={} operationId={} "
+ "responseCode={} requestId={} traceId={} expectedAction=RELEASE_OR_TRANSFER "
+ "releasedReservationIds={} remainingReservations={}",
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
response == null ? null : response.code(), response == null ? null : response.requestId(),
response == null ? null : response.traceId(), releasedReservationIds,
settlementTransferFacts(remainingItems));
|| response.data().blockId() != blockId || response.data().resources() == null
|| response.data().resources().isEmpty()
|| response.data().resources().stream().anyMatch(resource -> resource.blockId() != blockId)
|| !(childInboundId > 0 ? "OCCUPIED" : "RELEASED").equals(response.data().blockStatus())
|| response.data().resources().stream().anyMatch(resource ->
!(childInboundId > 0 ? "OCCUPIED" : "RELEASED").equals(resource.resourceStatus()))) {
log.warn("PDA warehouse block settlement rejected taskId={} sourceInboundId={} blockId={} "
+ "operationId={} responseCode={} requestId={} traceId={}",
taskId, parentInboundId, blockId, key, response == null ? null : response.code(),
response == null ? null : response.requestId(), response == null ? null : response.traceId());
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
throw new SettlementFailure(code, response == null || response.message() == null
? "仓储服务原子结算PDA库位资源失败" : response.message(),
@@ -517,40 +488,6 @@ public class PdaPickSettlementService {
public String traceId() { return traceId; }
}
private static List<String> settlementTransferFacts(
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount())
.sorted().toList();
}
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
try {
ApiResponse<Long> response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey);
return response != null && response.successful() && response.data() != null
? response.data() : 0L;
} catch (FeignException exception) {
return 0L;
}
}
private static boolean sameReleasedReservations(List<Long> expected,
List<WarehousePalletLocationClient.SelectiveReleaseResult> actual) {
return expected.equals(actual.stream().map(WarehousePalletLocationClient.SelectiveReleaseResult::reservationId)
.sorted().toList());
}
private static boolean sameTransferredResources(List<WarehousePalletLocationClient.SettlementTransferItem> expected,
List<WarehousePalletLocationClient.TransferResult> actual) {
Map<Long, Integer> expectedByLocation = expected.stream().collect(Collectors.toMap(
WarehousePalletLocationClient.SettlementTransferItem::reservationId,
WarehousePalletLocationClient.SettlementTransferItem::remainingPackageCount));
Map<Long, Integer> actualByLocation = actual.stream().collect(Collectors.toMap(
WarehousePalletLocationClient.TransferResult::reservationId,
WarehousePalletLocationClient.TransferResult::remainingPackageCount,
(left, right) -> -1));
return expectedByLocation.equals(actualByLocation);
}
static String childInboundCode(String rootInboundCode, int splitSequence) {
if (splitSequence <= 0) {
throw conflict("子入库单拆分序号不合法");
@@ -125,24 +125,28 @@ public class SmartLoadingService {
? EnumSet.allOf(CandidateOrderStatus.class)
: EnumSet.copyOf(query.orderStatuses());
boolean includePending = selected.remove(CandidateOrderStatus.PENDING_REVIEW);
InboundCandidateQuery firstlegQuery = withStatuses(query, List.copyOf(selected), 1, 1);
if (!includePending) return firstlegCandidates(withStatuses(query, List.copyOf(selected),
query.page(), query.size()), actualChannels);
if (selected.isEmpty()) return pendingReviewCandidates(query, actualChannels, query.page(), query.size());
PageResult<InboundCandidate> pendingCount = pendingReviewCandidates(query, actualChannels, 1, 1);
PageResult<InboundCandidate> firstlegCount = firstlegCandidates(firstlegQuery, actualChannels);
long total = pendingCount.total() + firstlegCount.total();
long offset = Math.multiplyExact(query.page() - 1, query.size());
PageResult<InboundCandidate> pendingPage = pendingReviewCandidates(query, actualChannels,
query.page(), query.size());
List<InboundCandidate> records = new ArrayList<>();
if (offset < pendingCount.total()) {
records.addAll(pendingReviewRange(query, actualChannels, offset, query.size()));
}
long firstlegOffset = Math.max(0, offset - pendingCount.total());
int remaining = Math.toIntExact(query.size() - records.size());
if (remaining > 0 && firstlegOffset < firstlegCount.total()) {
records.addAll(firstlegRange(query, actualChannels, List.copyOf(selected), firstlegOffset, remaining));
PageResult<InboundCandidate> firstlegPage;
if (offset < pendingPage.total()) {
records.addAll(pendingPage.records());
int remaining = Math.toIntExact(query.size() - records.size());
firstlegPage = firstlegCandidates(withStatuses(query, List.copyOf(selected), 1,
Math.max(1, remaining)), actualChannels);
if (remaining > 0) records.addAll(firstlegPage.records());
} else {
long firstlegOffset = offset - pendingPage.total();
firstlegPage = firstlegRange(query, actualChannels, List.copyOf(selected),
firstlegOffset, query.size());
records.addAll(firstlegPage.records());
}
long total = pendingPage.total() + firstlegPage.total();
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@@ -184,7 +188,7 @@ public class SmartLoadingService {
try {
response = pendingReviewOrders.page(internalToken, List.copyOf(actualChannels), query.warehouseId(),
query.customerId(), query.inboundCode(), isoDate(query.createdStart()), isoDate(query.createdEnd()),
page, size);
repository.occupiedOrderIds(query, actualChannels), page, size);
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单服务暂时不可用");
}
@@ -205,27 +209,20 @@ public class SmartLoadingService {
return value == null ? null : value.toString();
}
private List<InboundCandidate> pendingReviewRange(InboundCandidateQuery query, Set<String> channels,
long offset, long limit) {
return pageRange(offset, limit, (page, size) -> pendingReviewCandidates(query, channels, page, size));
}
private List<InboundCandidate> firstlegRange(InboundCandidateQuery query, Set<String> channels,
List<CandidateOrderStatus> statuses,
long offset, long limit) {
return pageRange(offset, limit, (page, size) -> firstlegCandidates(
withStatuses(query, statuses, page, size), channels));
}
private static List<InboundCandidate> pageRange(long offset, long limit,
java.util.function.BiFunction<Long, Long, PageResult<InboundCandidate>> source) {
if (limit <= 0) return List.of();
private PageResult<InboundCandidate> firstlegRange(InboundCandidateQuery query, Set<String> channels,
List<CandidateOrderStatus> statuses,
long offset, long limit) {
if (limit <= 0) return new PageResult<>(List.of(), 1, 1, 0, 0);
final long chunk = 200;
long page = offset / chunk + 1;
int skip = Math.toIntExact(offset % chunk);
List<InboundCandidate> result = new ArrayList<>();
long total = 0;
while (result.size() < limit) {
List<InboundCandidate> rows = source.apply(page++, chunk).records();
PageResult<InboundCandidate> sourcePage = firstlegCandidates(
withStatuses(query, statuses, page++, chunk), channels);
total = sourcePage.total();
List<InboundCandidate> rows = sourcePage.records();
if (rows.isEmpty()) break;
int from = Math.min(skip, rows.size());
int take = Math.min(Math.toIntExact(limit - result.size()), rows.size() - from);
@@ -233,7 +230,8 @@ public class SmartLoadingService {
skip = 0;
if (rows.size() < chunk) break;
}
return List.copyOf(result);
return new PageResult<>(List.copyOf(result), query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
private static InboundCandidateQuery withStatuses(InboundCandidateQuery source,
@@ -420,7 +418,6 @@ public class SmartLoadingService {
request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
var task = loadingPickService.mutationSnapshot(planId);
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
@@ -525,7 +522,6 @@ public class SmartLoadingService {
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream()
@@ -581,7 +577,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<Candidate> details = frozenDetails == null
? candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream()
? manualCandidates(booking).stream()
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
: List.copyOf(frozenDetails);
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
@@ -641,7 +637,6 @@ public class SmartLoadingService {
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
@@ -852,8 +847,14 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(),
item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
requireSameChannel(booking, candidate);
if (candidate == null) throw validation("调整箱数超过可用箱数");
int currentPackageCount = item.getPackageCount() == null ? 0 : item.getPackageCount();
int remainingPackageCount = Math.max(0, candidate.availablePackageCount() == null
? 0 : candidate.availablePackageCount());
if (request.packageCount() > currentPackageCount
&& request.packageCount() - currentPackageCount > remainingPackageCount) {
throw validation("调整箱数超过可用箱数");
}
requireSameCustomerScope(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
@@ -945,6 +946,84 @@ public class SmartLoadingService {
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "批量移除智能装柜未拣明细")
public Plan removeUnpicked(long planId, RemoveUnpickedRequest request,
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("REMOVE_UNPICKED|" + planId + '|' + request.expectedPlanVersion());
Plan replay = replay(operation, planId, "REMOVE_UNPICKED", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> items = repository.items(planId);
LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId);
Map<Long, Integer> picked = pickSnapshot == null ? Map.of() : pickSnapshot.pickedByPlanItem();
Map<String, GroupSnapshot> groupByMember = new HashMap<>();
Map<Long, GroupSnapshot> groups = new LinkedHashMap<>();
for (LoadingPlanItemEntity item : items) {
String memberKey = item.getAccountId() + "|" + item.getInboundId();
if (!groupByMember.containsKey(memberKey)) {
groupByMember.put(memberKey, group(item.getAccountId(), item.getInboundId()));
}
GroupSnapshot group = groupByMember.get(memberKey);
if (group != null) groups.putIfAbsent(group.groupId(), group);
}
Set<Long> groupsWithPickedItems = new HashSet<>();
for (LoadingPlanItemEntity item : items) {
if (picked.getOrDefault(item.getId(), 0) <= 0) continue;
GroupSnapshot group = groupByMember.get(item.getAccountId() + "|" + item.getInboundId());
if (group != null) groupsWithPickedItems.add(group.groupId());
}
List<LoadingPlanItemEntity> removable = items.stream().filter(item -> {
if (picked.getOrDefault(item.getId(), 0) > 0) return false;
GroupSnapshot group = groupByMember.get(item.getAccountId() + "|" + item.getInboundId());
return group == null || !groupsWithPickedItems.contains(group.groupId());
}).toList();
Set<Long> removableIds = removable.stream().map(LoadingPlanItemEntity::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> removableGroupIds = groups.values().stream()
.filter(group -> !groupsWithPickedItems.contains(group.groupId()))
.filter(group -> items.stream().anyMatch(item -> removableIds.contains(item.getId())
&& group.members().stream().anyMatch(member -> member.tenantId() == item.getAccountId()
&& member.inboundId() == item.getInboundId())))
.map(GroupSnapshot::groupId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
for (Long groupId : removableGroupIds) {
requireCompleteGroupAllocation(planId, groups.get(groupId), items);
}
if (!removable.isEmpty()) {
touch(plan, operatorId, operatorName);
for (LoadingPlanItemEntity item : removable) {
loadingPickService.remove(item.getId());
if (repository.remove(planId, item.getId(), item.getVersion(), operatorId, operatorName) != 1) {
throw conflict("装柜未拣明细已被并发修改");
}
}
Set<String> releasedInbounds = new HashSet<>();
for (LoadingPlanItemEntity item : removable) {
String key = item.getAccountId() + "|" + item.getInboundId();
if (releasedInbounds.add(key)) {
loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId());
}
}
for (Long groupId : removableGroupIds) {
GroupSnapshot group = groups.get(groupId);
coloadGroups.unlockWholeGroupAfterRemoval(groupId, planId,
"UNLOCK-" + sha256(operation + '|' + groupId).substring(0, 48),
operatorId, operatorName);
}
}
history(planId, null, "REMOVE_UNPICKED", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "itemCount", items.size())),
json(values("requestHash", requestHash, "removedItemCount", removable.size(),
"removedGroupCount", removableGroupIds.size())), operatorId, operatorName);
return view(repository.plan(planId), booking);
}
private List<Candidate> enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan,
List<Candidate> values) {
Map<String, GroupSnapshot> snapshots = new HashMap<>();
@@ -1013,7 +1092,7 @@ public class SmartLoadingService {
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
throw conflict("同柜不可拆组已锁定或已消费");
}
List<Candidate> available = availableCandidates(booking);
List<Candidate> available = manualCandidates(booking);
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
for (Member member : group.members()) {
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
@@ -1040,6 +1119,11 @@ public class SmartLoadingService {
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
}
private List<Candidate> manualCandidates(ContainerBookingEntity booking) {
return repository.candidates(booking.getWarehouseId()).stream()
.filter(candidate -> sameCustomerScope(booking, candidate)).toList();
}
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
GroupSnapshot group, List<Candidate> available) {
if (booking.getCapacityCbm() == null || booking.getMaxWeightKg() == null) return false;
@@ -1189,7 +1273,8 @@ public class SmartLoadingService {
private PendingReviewOrderClient.Row requirePendingReviewOrder(AssignPendingReviewRequest request) {
try {
ApiResponse<PageResult<PendingReviewOrderClient.Row>> response = pendingReviewOrders.page(
internalToken, null, null, request.customerId(), request.inboundCode(), null, null, 1, 2);
internalToken, null, null, request.customerId(), request.inboundCode(), null, null,
null, 1, 2);
if (response == null || !response.successful() || response.data() == null) throw unavailable();
return response.data().records().stream()
.filter(row -> Objects.equals(row.orderId(), request.orderId())
@@ -1272,7 +1357,6 @@ public class SmartLoadingService {
private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,
int count, boolean requireSameDestination){
requireSameChannel(b, c);
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (requireSameDestination && StringUtils.hasText(b.getDestinationWarehouseCode())
&& !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) {
@@ -1376,12 +1460,6 @@ public class SmartLoadingService {
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
}
private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) {
throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填");
}
if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装");
}
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
if (!sameCustomerScope(booking, candidate)) throw forbidden();
}
@@ -716,22 +716,29 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
block_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse整票连续库位块ID',
slot_no INT UNSIGNED NOT NULL COMMENT '连续块内稳定槽位序号',
weight_result_id BIGINT UNSIGNED NULL COMMENT '已绑定称重结果ID;预留空槽为空',
pallet_sequence INT UNSIGNED NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照',
pallet_id BIGINT UNSIGNED NULL COMMENT '仓库服务托盘ID;预留空槽为空',
pallet_code VARCHAR(50) NULL COMMENT '托盘编码快照;预留空槽为空',
pallet_capacity_cbm DECIMAL(18,4) NULL COMMENT '托盘可用容积快照;预留空槽为空',
allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通连续位不足时是否使用共享A0',
reservation_id BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位',
slot_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no),
UNIQUE KEY uk_pallet_allocation_weight (tenant_id, weight_result_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
KEY idx_allocation_reservation (reservation_id),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
@@ -466,7 +466,8 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中
对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态;实际分配仍由服务端重新校验仓库、渠道和资格。
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道,
仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
@@ -493,7 +494,7 @@ paths:
LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW;
Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING
=WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、
剩余箱数、同柜组、仓库、渠道和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。
剩余箱数、同柜组、仓库和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。
待审核及待测量可以先加入清单;确认预装/PDA 前
仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。
parameters:
@@ -597,7 +598,8 @@ paths:
summary: 向预装计划新增入库单明细
description: >-
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
校验仓库、渠道、客户范围、可用箱数、容积和限重后原子新增/累加明细;LOADING 时同步到原 taskId,
校验仓库、客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单渠道与柜次渠道不同,
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
PDA 应重新 GET task detail 与 inventory snapshot,Admin 应重新 GET SmartLoadingPlan。新增项沿用原 taskId、
@@ -616,6 +618,7 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数;
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
人工分配不限制订单渠道与柜次渠道一致,渠道只约束智能推荐;国内仓、占用、同柜组和容量门禁不变。
柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
parameters:
@@ -631,7 +634,8 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。已被其他有效柜次占用、仓库/渠道不一致、
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同;
已被其他有效柜次占用、国内仓不一致、
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
parameters:
@@ -670,6 +674,20 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:
delete:
summary: 一次性移除当前计划全部未拣明细
description: >-
需要 platform:firstleg:smart-loading:delete 权限。单次请求、单事务锁定计划与 PDA task,移除全部
pickedPackageCount=0 的普通入仓码;同柜不可拆组只在组内所有明细均未拣时整组移除,任一成员已拣则整组保留。
planVersion 只递增一次;并发版本变化或任一步失败均整体回滚。同 operationId 重放返回既有结果。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingRemoveUnpickedRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark:
patch:
summary: 修改装柜明细仓库备注
@@ -1029,6 +1047,7 @@ paths:
summary: 删除称重图片及重量结果
description: >-
需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。
已绑定图片先将对应槽恢复为 RESERVED_EMPTY,整票块仍归当前入仓码;解绑或文件删除失败时事务失败。
删除已确认图片后由生产者分别重算剩余确认称重的毛重、净重;消费端不得用净重填充毛重。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1604,6 +1623,14 @@ components:
adjustedAt: {type: string, format: date-time, nullable: true}
confirmedBy: {type: string, nullable: true}
confirmedAt: {type: string, format: date-time, nullable: true}
palletSequence: {type: integer, minimum: 1, nullable: true, description: 已绑定称重托盘在整票内的稳定顺序;未绑定槽位时为空}
blockId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 整票连续库位块标识}
slotNo: {type: integer, minimum: 1, nullable: true, description: 整票连续块内的稳定槽位序号}
reservationId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 预占资源唯一标识;A0 共享时仍按此字段区分}
locationId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 库位标识}
locationCode: {type: string, nullable: true, description: 库位业务编码,例如 A01 或 A0}
sharedOverflow: {type: boolean, nullable: true, description: 是否因普通连续位不足而使用共享 A0}
slotStatus: {type: string, enum: [RESERVED_EMPTY, BOUND, TRANSFERRED, RELEASED], nullable: true, description: 当前整票槽位状态}
version: {type: integer, minimum: 0}
InboundMeasurementItem:
type: object
@@ -1698,6 +1725,38 @@ components:
type: array
description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实
items: {$ref: '#/components/schemas/InboundWeightResult'}
palletLocationGuide:
allOf: [{$ref: '#/components/schemas/InboundPalletLocationGuide'}]
nullable: true
description: 服务端权威的整票上架引导;尚未预留库位时为空,禁止由 weightResults 数量或数组下标推导
InboundPalletLocationGuide:
type: object
required: [blockId, reservedSlotCount, actualPalletCount, reservedEmptySlotCount, slots]
properties:
blockId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse 整票连续库位块标识}
reservedSlotCount: {type: integer, minimum: 1, description: 整票当前预留槽位总数,等于 slots 长度}
actualPalletCount: {type: integer, minimum: 0, description: 已绑定称重结果的实际托数}
reservedEmptySlotCount: {type: integer, minimum: 0, description: slotStatus=RESERVED_EMPTY 的预留空位数}
slots:
type: array
minItems: 1
maxItems: 200
description: 严格按 slotNo 升序返回的整票槽位
items: {$ref: '#/components/schemas/InboundPalletLocationSlot'}
InboundPalletLocationSlot:
type: object
required: [slotNo, reservationId, locationId, locationCode, sharedOverflow, slotStatus]
properties:
slotNo: {type: integer, minimum: 1}
reservationId: {type: string, pattern: '^[1-9][0-9]*$'}
locationId: {type: string, pattern: '^[1-9][0-9]*$'}
locationCode: {type: string}
sharedOverflow: {type: boolean}
slotStatus: {type: string, enum: [RESERVED_EMPTY, BOUND, TRANSFERRED, RELEASED]}
boundWeightResultId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
palletSequence: {type: integer, minimum: 1, nullable: true}
palletId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
palletCode: {type: string, nullable: true}
InboundMeasurementLatestDeclaration:
type: object
description: Order 服务中的最新客户申报只读视图;订单编辑只能改变本对象,不得覆盖箱码、实测尺寸/重量或审核结果
@@ -1956,6 +2015,12 @@ components:
expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingRemoveUnpickedRequest:
type: object
required: [expectedPlanVersion, operationId]
properties:
expectedPlanVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingWarehouseRemarkRequest:
type: object
required: [expectedPlanVersion, expectedItemVersion, operationId]
@@ -368,10 +368,6 @@ components:
action: {type: string, enum: [PICK, PARTIAL, SKIP]}
pickedPackageCount: {type: integer, minimum: 1, description: Incremental count required by PICK}
finalPickedPackageCount: {type: integer, minimum: 1, description: Final actual count required by PARTIAL}
releaseMode: {type: string, enum: [AUTO, MANUAL]}
reservationIds:
type: array
items: {type: integer, format: int64, minimum: 1}
version: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500, description: Required by SKIP}
ReviewRequest:
@@ -24,7 +24,8 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
assertThat(detailProperties).containsKeys(
"actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults");
"actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults",
"palletLocationGuide");
assertThat(stringMap(detailProperties.get("latestDeclaration")))
.containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration");
assertThat(properties(schemas, "InboundMeasurementLatestDeclaration"))
@@ -52,7 +53,16 @@ class InboundMeasurementEvidenceOpenApiContractTest {
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
assertThat(weightProperties).containsKeys(
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt");
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt",
"palletSequence", "blockId", "slotNo", "reservationId", "locationId", "locationCode",
"sharedOverflow", "slotStatus");
assertThat(stringMap(weightProperties.get("slotStatus"))).containsEntry(
"enum", java.util.List.of("RESERVED_EMPTY", "BOUND", "TRANSFERRED", "RELEASED"));
assertThat(properties(schemas, "InboundPalletLocationGuide")).containsKeys(
"blockId", "reservedSlotCount", "actualPalletCount", "reservedEmptySlotCount", "slots");
assertThat(properties(schemas, "InboundPalletLocationSlot")).containsKeys(
"slotNo", "reservationId", "locationId", "locationCode", "sharedOverflow", "slotStatus",
"boundWeightResultId", "palletSequence", "palletId", "palletCode");
assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString())
.contains("CONFIRMED", "grossWeightKg", "含托盘毛重");
assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString())
@@ -49,6 +49,9 @@ class PdaConvergenceOpenApiContractTest {
String batchRequest = yaml.substring(
yaml.indexOf(" BatchRequest:"),
yaml.indexOf(" BatchItemRequest:"));
String batchItemRequest = yaml.substring(
yaml.indexOf(" BatchItemRequest:"),
yaml.indexOf(" ReviewRequest:"));
assertThat(taskLookup)
.contains("name: cabinetNo", "name: loadingPlanId")
.doesNotContain("name: containerNo");
@@ -59,5 +62,7 @@ class PdaConvergenceOpenApiContractTest {
assertThat(batchRequest)
.contains("loadingPlanId:", "containerBookingId:")
.doesNotContain("containerNo:");
assertThat(batchItemRequest)
.doesNotContain("releaseMode:", "reservationIds:", "locationIds:");
}
}
@@ -56,6 +56,7 @@ class PlatformSmartLoadingControllerContractTest {
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("removeUnpicked","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,RemoveUnpickedRequest.class);
}
@Test void openApiHasNoManualCreateOrPushStep() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
@@ -75,6 +76,7 @@ class PlatformSmartLoadingControllerContractTest {
.contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:")
.contains("SmartLoadingDeleteRequest:")
.contains("SmartLoadingRemoveUnpickedRequest:")
.contains("SmartLoadingWarehouseRemarkRequest:")
.contains("/items/{itemId}/warehouse-remark:")
.contains("需要 platform:firstleg:preload:edit 权限")
@@ -83,6 +85,8 @@ class PlatformSmartLoadingControllerContractTest {
.contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%")
.contains("人工分配不限制订单渠道与柜次渠道一致")
.contains("渠道只约束智能推荐")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -134,9 +134,42 @@ class CabinetLoadingEvidenceServiceTest {
verify(files, times(1)).upload(anyString(), anyLong(), anyLong(), anyString(), any());
}
@Test
void multipartReplayRejectsChangedContentBeforeUploadingAnotherFile() {
var first = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1, 2, 3});
var changed = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1, 2, 4});
CabinetLoadingEvidenceEntity existing = evidence("PHOTO_STEP_1");
existing.setSourceTaskId(20L);
existing.setOperationKey("same-key");
existing.setRequestHash(uploadHashForTest(8L, 0, "PHOTO_STEP_1", null, "remark", first));
when(mapper.byOperation(20L, "same-key")).thenReturn(existing);
assertThatThrownBy(() -> service.upload(20L, 8L, "same-key", 0, "PHOTO_STEP_1",
null, "remark", changed, 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("请求内容不一致");
verify(files, never()).upload(anyString(), anyLong(), anyLong(), anyString(), any());
}
private static CabinetLoadingEvidenceEntity evidence(String type) {
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setEvidenceType(type); value.setStatus("ACTIVE"); value.setContainerBookingId(99L);
return value;
}
private static String uploadHashForTest(long warehouseId, int expectedVersion, String evidenceType,
String sealNo, String remark, MockMultipartFile multipart) {
try {
var digest = java.security.MessageDigest.getInstance("SHA-256");
String metadata = warehouseId + "|" + expectedVersion + "|" + evidenceType + "|"
+ java.util.Objects.toString(sealNo, "") + "|" + java.util.Objects.toString(remark, "") + "|"
+ java.util.Objects.toString(multipart.getOriginalFilename(), "") + "|"
+ java.util.Objects.toString(multipart.getContentType(), "") + "|" + multipart.getSize() + "|";
digest.update(metadata.getBytes(java.nio.charset.StandardCharsets.UTF_8));
digest.update(multipart.getBytes());
return java.util.HexFormat.of().formatHex(digest.digest());
} catch (java.io.IOException | java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
}
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -27,6 +28,7 @@ class LoadingDeliveryServiceTest {
@MockBean LoadingCompletionGateService completionGate;
@MockBean InboundStatusHistoryMapper histories;
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean FbaPricingService pricingService;
@MockBean ObjectMapper objectMapper;
@Resource LoadingDeliveryService service;
@@ -46,6 +48,7 @@ class LoadingDeliveryServiceTest {
new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done"));
verify(cabinetEvidence).requireComplete(99L);
verify(pricingService).autoConfirmAfterLoading(10L, 20L);
}
@Test
@@ -86,6 +89,7 @@ class LoadingDeliveryServiceTest {
verify(repository).findInboundForUpdate(10L, 21L);
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
verify(pricingService).autoConfirmAfterLoading(10L, 21L);
}
@Test
@@ -1,84 +1,104 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.math.BigDecimal;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPalletAllocationRepositoryTest {
@Mock
private InboundPalletAllocationMapper mapper;
@InjectMocks
private InboundPalletAllocationRepository repository;
@Mock private InboundPalletAllocationMapper mapper;
@InjectMocks private InboundPalletAllocationRepository repository;
@Test
void acceptsReleasedSnapshotWhenRetryUpdateAffectsNoRows() {
InboundPalletAllocationEntity current = allocation(1L, 91L, "RELEASED", 10, 10);
when(mapper.markReleased(10001L, 31L, 91L)).thenReturn(0);
when(mapper.selectOne(any())).thenReturn(current);
void savesEveryBlockSlotIncludingReservedEmptySlots() {
var result = new WarehousePalletLocationClient.BlockResult(700L, "OCCUPIED", List.of(
resource(700L, 91L, 11L, "A01", 1, false),
resource(700L, 92L, 12L, "A02", 2, false),
resource(700L, 93L, 99L, "A0", 3, true)));
assertThatCode(() -> repository.markReleased(10001L, 31L, List.of(91L)))
.doesNotThrowAnyException();
repository.replaceBlock(10001L, 31L, 88L, 20, new BigDecimal("2.5000"), result);
ArgumentCaptor<InboundPalletAllocationEntity> inserted =
ArgumentCaptor.forClass(InboundPalletAllocationEntity.class);
verify(mapper, times(3)).insert(inserted.capture());
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getSlotNo)
.containsExactly(1, 2, 3);
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getWeightResultId)
.containsOnlyNulls();
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getSlotStatus)
.containsOnly("RESERVED_EMPTY");
assertThat(inserted.getAllValues().get(2).getSharedOverflow()).isTrue();
}
@Test
void acceptsTransferredSnapshotWhenRetryUpdateAffectsNoRows() {
InboundPalletAllocationEntity source = allocation(1L, 91L, "CONFIRMED", 10, 2);
InboundPalletAllocationEntity current = allocation(1L, 91L, "TRANSFERRED", 10, 2);
when(mapper.markTransferred(1L, 10001L, 31L)).thenReturn(0);
when(mapper.selectOne(any())).thenReturn(current);
void bindsConfirmedWeightsByConfirmationTimeToTheNextEmptySlot() {
InboundPalletAllocationEntity firstEmpty = allocation(1L, 1);
InboundPalletAllocationEntity secondEmpty = allocation(2L, 2);
when(mapper.selectNextEmptyForUpdate(10001L, 31L)).thenReturn(firstEmpty, secondEmpty);
when(mapper.bindWeight(any(Long.class), any(Long.class), any(Long.class), any(Long.class),
any(Integer.class), any(Long.class), any(String.class), any(BigDecimal.class))).thenReturn(1);
InboundWeightResultEntity later = weight(102L, LocalDateTime.of(2026, 9, 4, 10, 2));
InboundWeightResultEntity earlier = weight(101L, LocalDateTime.of(2026, 9, 4, 10, 1));
assertThatCode(() -> repository.markTransferred(10001L, 31L, List.of(source), List.of()))
.doesNotThrowAnyException();
repository.bindConfirmedWeights(10001L, 31L, List.of(later, earlier));
org.mockito.InOrder order = org.mockito.Mockito.inOrder(mapper);
order.verify(mapper).bindWeight(1L, 10001L, 31L, 101L, 1, 501L, "P-501",
new BigDecimal("1.2000"));
order.verify(mapper).bindWeight(2L, 10001L, 31L, 102L, 2, 501L, "P-501",
new BigDecimal("1.2000"));
}
@Test
void restoresReservationNumbersByReservationIdWhenPalletIsReused() {
InboundPalletAllocationEntity first = allocation(1L, 91L, "RESERVED", 1, 0);
first.setPalletId(501L);
first.setReservationNo("LR-91");
InboundPalletAllocationEntity second = allocation(2L, 92L, "RESERVED", 1, 0);
second.setPalletId(501L);
second.setReservationNo("LR-92");
when(mapper.selectList(any())).thenReturn(List.of(first, second));
void unbindRestoresOnlyTheBoundSlotToReservedEmpty() {
InboundPalletAllocationEntity bound = allocation(1L, 1);
bound.setWeightResultId(101L);
when(mapper.selectOne(any())).thenReturn(bound);
when(mapper.unbindWeight(1L, 10001L, 31L, 101L)).thenReturn(1);
var result = new WarehousePalletLocationClient.BatchResult(2, List.of(
new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", new BigDecimal("1.2"), 11L, "A1",
new BigDecimal("1.2"), "OCCUPIED"),
new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", new BigDecimal("1.2"), 12L, "A2",
new BigDecimal("1.2"), "OCCUPIED")));
repository.unbindWeight(10001L, 31L, 101L);
repository.replace(10001L, 31L, 88L, 0L, 2, result);
ArgumentCaptor<InboundPalletAllocationEntity> inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class);
verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture());
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getReservationNo)
.containsExactly("LR-91", "LR-92");
verify(mapper).unbindWeight(1L, 10001L, 31L, 101L);
}
private static InboundPalletAllocationEntity allocation(long id, long reservationId, String status,
int allocated, int picked) {
InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity();
entity.setId(id);
entity.setReservationId(reservationId);
entity.setAllocationStatus(status);
entity.setAllocatedPackageCount(allocated);
entity.setPickedPackageCount(picked);
return entity;
private static WarehousePalletLocationClient.BlockResource resource(
long blockId, long reservationId, long locationId, String code, int slotNo, boolean overflow) {
return new WarehousePalletLocationClient.BlockResource(
blockId, reservationId, locationId, code, slotNo, overflow, "OCCUPIED");
}
private static InboundPalletAllocationEntity allocation(long id, int slotNo) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setId(id);
value.setSlotNo(slotNo);
return value;
}
private static InboundWeightResultEntity weight(long id, LocalDateTime confirmedAt) {
InboundWeightResultEntity value = new InboundWeightResultEntity();
value.setId(id);
value.setRecognitionStatus("CONFIRMED");
value.setConfirmedAt(confirmedAt);
value.setPalletId(501L);
value.setPalletCode("P-501");
value.setPalletCapacityCbm(new BigDecimal("1.2000"));
return value;
}
}
@@ -90,6 +90,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("status.name() == 'WAIT_MEASURE'")
.contains("status.name() == 'WAIT_SPLIT'")
.contains("status.name() == 'LOADING'")
.contains("NOT EXISTS (", "active_item.inbound_id=o.id", "active_item.deleted=0")
.contains("current_group.id groupId")
.contains("group_member_count.group_id=current_group.id")
.doesNotContain("fl_container_booking")
@@ -36,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
@@ -69,6 +70,48 @@ class FbaPricingServiceTest {
verify(repository, never()).findLatestFee(TENANT, INBOUND);
}
@Test
@DisplayName("装柜完成后系统自动确认客户未确认的已发送费用")
void automaticallyConfirmsSentFeeAfterLoading() {
InboundFeeEntity sent = savedFee("SENT");
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.confirmFeeByCustomer(TENANT, 501L, 1, 0L,
"装柜完成自动确认", NOW)).thenReturn(1);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository).confirmFeeByCustomer(TENANT, 501L, 1, 0L,
"装柜完成自动确认", NOW);
}
@Test
@DisplayName("客户已经确认时装柜完成不覆盖原确认信息")
void keepsExistingCustomerConfirmationAfterLoading() {
InboundFeeEntity sent = savedFee("SENT");
sent.setCustomerConfirmedBy(88L);
sent.setCustomerConfirmedByName("客户成员");
sent.setCustomerConfirmedAt(NOW.minusHours(1));
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
anyLong(), anyString(), any());
}
@Test
@DisplayName("没有已发送费用时装柜完成不受影响")
void ignoresMissingOrUnsentFeeAfterLoading() {
when(repository.findLatestFeeForUpdate(TENANT, INBOUND))
.thenReturn(null, savedFee("CONFIRMED"));
service.autoConfirmAfterLoading(TENANT, INBOUND);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
anyLong(), anyString(), any());
}
@Test
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
@@ -98,7 +141,7 @@ class FbaPricingServiceTest {
assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(fee.getValue().getChargeRuleId()).isNull();
assertThat(fee.getValue().getCarrierId()).isNull();
assertThat(fee.getValue().getCustomerId()).isEqualTo(11L);
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
assertThat(fee.getValue().getConversionFactor()).isEqualByComparingTo("167");
assertThat(fee.getValue().getRoundingScale()).isEqualTo(6);
assertThat(fee.getValue().getRoundingMode()).isEqualTo("HALF_UP");
@@ -134,19 +177,37 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("缺少订单客户归属时拒绝生成对客费用")
void rejectsMissingOrderCustomerOwnership() {
@DisplayName("指定实际测量来源时必须存在测量批次和明细")
void requiresActualMeasurementDetailsForActualSource() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "0.7880");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW,
new BigDecimal("100.000"), new BigDecimal("0.7880"),
"ACTUAL", "ACTUAL", "calc-actual-missing"))
.isInstanceOf(BizException.class)
.hasMessageContaining("实际测量数据不存在");
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).insertFee(any());
}
@Test
@DisplayName("订单客户字段为空时使用租户归属生成对客费用")
void usesTenantOwnershipWhenOrderCustomerIsMissing() {
readyRepository();
InboundPricingEntity inbound = inbound("200.000", "0.2000");
inbound.setCustomerId(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "200.000", "2.000000", "400.00", "0.00"));
assertThatThrownBy(() -> service.calculate(
TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少订单客户归属");
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).insertFee(any());
assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"))
.isNotNull();
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
}
@Test
@@ -12,9 +12,11 @@ import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -58,6 +60,7 @@ class InboundMeasurementServiceTest {
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@MockBean private WarehousePalletLocationClient warehousePallets;
@MockBean private InboundPalletAllocationRepository palletAllocations;
@Resource private InboundMeasurementService service;
@BeforeEach
@@ -205,6 +208,10 @@ class InboundMeasurementServiceTest {
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
InboundPalletAllocationEntity bound = locationSlot(1, 801L, "A01", "BOUND", 50L);
InboundPalletAllocationEntity secondBound = locationSlot(2, 802L, "A02", "BOUND", 51L);
InboundPalletAllocationEntity empty = locationSlot(3, 803L, "A03", "RESERVED_EMPTY", null);
when(palletAllocations.list(1L, 10L)).thenReturn(List.of(empty, secondBound, bound));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
@@ -224,6 +231,16 @@ class InboundMeasurementServiceTest {
assertThat(confirmedWeight.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(confirmedWeight.netWeightKg()).isEqualByComparingTo("11.500");
assertThat(confirmedWeight.confirmedAt()).isNotNull();
assertThat(confirmedWeight.locationCode()).isEqualTo("A01");
});
assertThat(result.palletLocationGuide()).satisfies(guide -> {
assertThat(guide.blockId()).isEqualTo(700L);
assertThat(guide.reservedSlotCount()).isEqualTo(3);
assertThat(guide.actualPalletCount()).isEqualTo(2);
assertThat(guide.reservedEmptySlotCount()).isEqualTo(1);
assertThat(guide.slots()).extracting(InboundMeasurementResponses.PalletLocationSlot::locationCode)
.containsExactly("A01", "A02", "A03");
assertThat(guide.slots().get(2).boundWeightResultId()).isNull();
});
}
@@ -537,6 +554,23 @@ class InboundMeasurementServiceTest {
assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500");
}
@Test
void confirmingAWeightBindsItToTheNextEmptySlotWhenTheTicketAlreadyHasABlock() {
InboundOrderEntity inbound = inbound("MEASURE_REJECTED");
InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(palletAllocations.list(1L, 10L)).thenReturn(List.of(
locationSlot(1, 801L, "A01", "RESERVED_EMPTY", null)));
service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
verify(palletAllocations).bindNextEmpty(1L, 10L, weight);
}
@Test
void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -834,6 +868,26 @@ class InboundMeasurementServiceTest {
verify(repository).updateInboundWeightSummary(inbound);
}
@Test
void deletingBoundWeightUnbindsTheSlotBeforeDeletingTheResultAndFailsOnFileError() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.deleteWeight(1L, 10L, 20L, 1)).thenReturn(true);
when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L))
.thenReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE,
"file unavailable", null, null));
assertThatThrownBy(() -> service.deleteWeightResult(1L, 10L, 20L, 1, 9L))
.isInstanceOf(BizException.class).hasMessageContaining("file unavailable");
org.mockito.InOrder order = org.mockito.Mockito.inOrder(palletAllocations, repository, imageClient);
order.verify(palletAllocations).unbindWeight(1L, 10L, 20L);
order.verify(repository).deleteWeight(1L, 10L, 20L, 1);
order.verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L);
}
@Test
void deletingLastConfirmedWeightClearsInboundTotal() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -1051,6 +1105,21 @@ class InboundMeasurementServiceTest {
verify(batchService, never()).saveBatch(any());
}
private static InboundPalletAllocationEntity locationSlot(
int slotNo, long locationId, String locationCode, String slotStatus, Long weightResultId) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setBlockId(700L);
value.setSlotNo(slotNo);
value.setReservationId(900L + slotNo);
value.setLocationId(locationId);
value.setLocationCode(locationCode);
value.setSharedOverflow(false);
value.setSlotStatus(slotStatus);
value.setWeightResultId(weightResultId);
value.setPalletSequence(weightResultId == null ? null : slotNo);
return value;
}
private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
@@ -341,7 +342,7 @@ class LoadingPickServiceTest {
preparePick(task, item, "pick-1");
when(repository.allTaskItemsResolved(20L)).thenReturn(false);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-1"), 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(repository).syncInboundProjections(20L, 9L, "operator");
@@ -357,7 +358,7 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-2"), 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
@@ -373,7 +374,7 @@ class LoadingPickServiceTest {
preparePick(task, item, "pick-start-loading");
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-loading"),
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-loading"),
9L, "operator");
verify(smartLoadingRepository).startPicking(10L, 9L, "operator");
@@ -392,7 +393,7 @@ class LoadingPickServiceTest {
new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4));
when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-inbound"),
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-inbound"),
9L, "operator");
verify(repository).beginInboundLoading(101L, 50L, 9L, 4);
@@ -418,7 +419,7 @@ class LoadingPickServiceTest {
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 5));
when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(0);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-concurrent-inbound"),
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-concurrent-inbound"),
9L, "operator");
verify(repository).beginInboundLoading(101L, 50L, 9L, 4);
@@ -513,6 +514,104 @@ class LoadingPickServiceTest {
verify(repository, times(1)).createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED");
}
@Test
void claimRechecksSameOperationAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickOperationEntity completed = taskOperation("claim-race", "CLAIM");
when(repository.operation("claim-race")).thenReturn(null);
when(repository.operationForUpdate("claim-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of());
Task replay = service.claim(20L, new ClaimRequest(0, "claim-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString());
verify(smartLoadingRepository, never()).startPicking(anyLong(), anyLong(), anyString());
}
@Test
void assignRechecksSameOperationAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickOperationEntity completed = taskOperation("assign-race", "ASSIGN");
when(repository.operation("assign-race")).thenReturn(null);
when(repository.operationForUpdate("assign-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of());
Task replay = service.assign(20L, new AssignRequest(11L, "picker", 0, "assign-race"),
9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString());
verify(repository, never()).reassign(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString(), anyInt());
}
@Test
void pickRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("pick-race", "PICK",
"20|30|101|2");
when(repository.history("pick-race")).thenReturn(null);
when(repository.historyForUpdate("pick-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.pick(20L, 30L,
new PickRequest(101L, 2, 0, "pick-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).pick(anyLong(), anyInt(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void partialRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("partial-race", "PARTIAL",
"PARTIAL|20|30|101|1");
when(repository.history("partial-race")).thenReturn(null);
when(repository.historyForUpdate("partial-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.partial(20L, 30L,
new PartialPickRequest(101L, 1, 0, "partial-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).partial(anyLong(), anyInt(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void skipRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("skip-race", "SKIP",
"SKIP|20|30|101|damaged");
when(repository.history("skip-race")).thenReturn(null);
when(repository.historyForUpdate("skip-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.skip(20L, 30L,
new SkipRequest(101L, 0, "skip-race", "damaged"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).skip(anyLong(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void keepsPartialPickLockedUntilManualCompletion() {
LoadingPickTaskEntity task = task();
@@ -521,7 +620,7 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"),
service.partial(20L, 30L, new PartialPickRequest(101L, 1, 0, "partial-1"),
9L, "operator");
verify(repository).touchTask(20L, 0);
@@ -1046,7 +1145,7 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items);
when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.skip(31L, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
@@ -1056,13 +1155,13 @@ class LoadingPickServiceTest {
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton")));
new BatchItemRequest(30L, "PICK", 2, null, 0, null),
new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton")));
service.batch(20L, "batch-1", request, 9L, "operator");
service.batch(20L, "batch-1", request, 9L, "operator");
verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L);
verify(repository, times(1)).pick(30L, 2, 0, 101L);
verify(repository, times(1)).skip(31L, 0, 101L);
verify(repository, times(1)).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
@@ -1087,7 +1186,7 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.pick(30L, 100, "AUTO", "[]", 0, 101L)).thenReturn(1);
when(repository.pick(30L, 100, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1);
@@ -1097,11 +1196,11 @@ class LoadingPickServiceTest {
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 100))));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 100, null, "AUTO", List.of(), 0, null)));
new BatchItemRequest(30L, "PICK", 100, null, 0, null)));
service.batch(20L, "batch-reset-plan", request, 9L, "operator");
verify(repository).pick(30L, 100, "AUTO", "[]", 0, 101L);
verify(repository).pick(30L, 100, 0, 101L);
verify(smartLoadingRepository).startPicking(10L, 9L, "operator");
verify(smartLoadingRepository, never()).insert(any(LoadingPlanHistoryEntity.class));
verify(repository).touchTask(20L, 0);
@@ -1123,14 +1222,14 @@ class LoadingPickServiceTest {
when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null)));
new BatchItemRequest(30L, "PICK", 2, null, 0, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator"))
.isInstanceOf(BizException.class);
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(), any(Integer.class), any(Long.class));
verify(repository, never()).partial(any(Long.class), any(Integer.class), any(), any(), any(Integer.class), any(Long.class));
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class));
verify(repository, never()).partial(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
verify(settlementService, never()).settle(20L, 9L, "operator");
}
@@ -1147,13 +1246,13 @@ class LoadingPickServiceTest {
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null)));
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("库存已变化");
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(),
verify(repository, never()).pick(any(Long.class), any(Integer.class),
any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
verify(settlementService, never()).settle(20L, 9L, "operator");
@@ -1165,7 +1264,7 @@ class LoadingPickServiceTest {
task.setVersion(3);
when(repository.taskForUpdate(20L)).thenReturn(task);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator"))
.isInstanceOf(BizException.class);
@@ -1183,15 +1282,14 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn(
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchItemRequest line = new BatchItemRequest(
30L, "PICK", 2, null, "MANUAL", List.of(), 0, null);
BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null);
assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest(
101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator"))
@@ -1203,7 +1301,7 @@ class LoadingPickServiceTest {
service.batch(20L, "batch-valid", new BatchRequest(
101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator");
verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L);
verify(repository, times(1)).pick(30L, 2, 0, 101L);
verify(repository, times(1)).touchTask(20L, 0);
}
@@ -1224,7 +1322,7 @@ class LoadingPickServiceTest {
BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1,
"stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest,
9L, "operator")).isInstanceOf(BizException.class);
@@ -1232,7 +1330,7 @@ class LoadingPickServiceTest {
assertThat(pending.getPickedPackageCount()).isZero();
assertThat(pending.getItemStatus()).isEqualTo("PENDING");
verify(repository, never()).update(any(LoadingPickItemEntity.class));
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(),
verify(repository, never()).pick(any(Long.class), any(Integer.class),
any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
}
@@ -1251,7 +1349,7 @@ class LoadingPickServiceTest {
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
@@ -1262,7 +1360,7 @@ class LoadingPickServiceTest {
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.partial(30L, 1, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.partial(30L, 1, 0, 101L)).thenReturn(1);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
@@ -1316,6 +1414,41 @@ class LoadingPickServiceTest {
return assignment;
}
private static LoadingPickOperationEntity taskOperation(String operationId, String action) {
LoadingPickOperationEntity operation = new LoadingPickOperationEntity();
operation.setPickTaskId(20L);
operation.setOperationId(operationId);
operation.setRequestHash(hashForTest(action + "|20|0"));
if ("ASSIGN".equals(action)) {
operation.setRequestHash(hashForTest("ASSIGN|20|11|picker|0"));
}
operation.setActionType(action);
operation.setOperatorId(9L);
operation.setOperatorName("operator");
return operation;
}
private static LoadingPickHistoryEntity itemHistory(String operationId, String action, String canonical) {
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(20L);
history.setPickItemId(30L);
history.setOperationId(operationId);
history.setRequestHash(hashForTest(canonical));
history.setActionType(action);
history.setOperatorId(9L);
history.setOperatorName("operator");
return history;
}
private static String hashForTest(String value) {
try {
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static GroupSnapshot coloadGroup(String status, Long lockedPlanId, Member... members) {
GroupSnapshot group = mock(GroupSnapshot.class);
when(group.groupId()).thenReturn(700L);
@@ -462,7 +462,7 @@ class MeasurementBatchServiceTest {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
inbound.setTenantId(1L);
inbound.setCustomerId(1L);
inbound.setCustomerId(null);
inbound.setProcessStatus("WAIT_MEASURE");
inbound.setVersion(0);
return inbound;
@@ -52,37 +52,38 @@ class MeasurementLocationOccupationServiceTest {
properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("OCCUPIED")));
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
ArgumentCaptor<WarehousePalletLocationClient.ReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class);
verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.BlockReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class);
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND);
assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-88-30-3-");
assertThat(reserve.getValue().pallets())
.extracting(WarehousePalletLocationClient.PalletReference::palletId)
.containsExactly(601L, 601L, 601L);
assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-block-88-30-3-");
assertThat(reserve.getValue().requiredSlotCount()).isEqualTo(3);
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.BlockConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockConfirmRequest.class);
verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND);
assertThat(confirm.getValue().packageCount()).isEqualTo(20);
assertThat(confirm.getValue().blockId()).isEqualTo(500L);
assertThat(confirm.getValue().idempotencyKey())
.isEqualTo(reserve.getValue().idempotencyKey() + "-confirm");
verify(allocations, times(2)).replace(
verify(allocations, times(2)).replaceBlock(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")),
org.mockito.ArgumentMatchers.any());
verify(allocations, times(2)).bindConfirmedWeights(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.anyList());
}
@Test
@@ -90,33 +91,51 @@ class MeasurementLocationOccupationServiceTest {
properties.setWarehouseResourceTenantId(0L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("OCCUPIED")));
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
ArgumentCaptor<WarehousePalletLocationClient.ReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class);
verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.BlockReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class);
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().warehouseId()).isEqualTo(WAREHOUSE);
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(confirm.getValue().packageCount()).isEqualTo(20);
ArgumentCaptor<WarehousePalletLocationClient.BlockConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockConfirmRequest.class);
verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations, times(2)).replace(
verify(allocations, times(2)).replaceBlock(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")),
org.mockito.ArgumentMatchers.any());
}
@Test
void reservesThreeSlotsForVolumeWhileBindingOnlyTwoConfirmedWeightImages() {
InboundOrderEntity inbound = inbound();
inbound.setActualVolumeCbm(new BigDecimal("2.5000"));
List<InboundWeightResultEntity> twoWeights = confirmedWeights().subList(0, 2);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound);
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(twoWeights);
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3));
verify(allocations, times(2)).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, twoWeights);
}
@Test
void rejectsConfirmedWeightWithoutConfirmationTimeInsteadOfDroppingIt() {
InboundWeightResultEntity weight = confirmedWeights().get(0);
@@ -128,7 +147,7 @@ class MeasurementLocationOccupationServiceTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少确认时间");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -142,7 +161,7 @@ class MeasurementLocationOccupationServiceTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效净重");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -156,7 +175,7 @@ class MeasurementLocationOccupationServiceTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效托盘");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -164,24 +183,23 @@ class MeasurementLocationOccupationServiceTest {
properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
IllegalStateException failure = new IllegalStateException("confirm failed");
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenThrow(failure);
when(warehouse.release(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(List.of()));
when(warehouse.releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RELEASED", 3)));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isSameAs(failure);
ArgumentCaptor<WarehousePalletLocationClient.ReleaseRequest> release =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReleaseRequest.class);
verify(warehouse).release(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture());
assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.BlockReleaseRequest> release =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReleaseRequest.class);
verify(warehouse).releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture());
assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT,
20, batch("RESERVED"));
verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE,
20, new BigDecimal("1.0000"), block("RESERVED", 3));
}
private static InboundProperties properties() {
@@ -209,12 +227,12 @@ class MeasurementLocationOccupationServiceTest {
return value;
}
private static WarehousePalletLocationClient.BatchResult batch(String state) {
return new WarehousePalletLocationClient.BatchResult(3, java.util.stream.IntStream.rangeClosed(1, 3)
.mapToObj(index -> new WarehousePalletLocationClient.AllocationResult(
500L + index, "RES-00" + index, index, 601L, "PL-001", new BigDecimal("1.200000"),
700L + index, "A-00" + index, new BigDecimal("0.333333"), state))
.toList());
private static WarehousePalletLocationClient.BlockResult block(String state, int count) {
return new WarehousePalletLocationClient.BlockResult(500L, state,
java.util.stream.IntStream.rangeClosed(1, count)
.mapToObj(index -> new WarehousePalletLocationClient.BlockResource(
500L, 600L + index, 700L + index, "A-00" + index, index, false, state))
.toList());
}
private static List<InboundWeightResultEntity> confirmedWeights() {
@@ -56,9 +56,6 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(
transfer(1, 501L, 2), transfer(2, 502L, 10)))));
writes(12, 8);
service.settle(1L, 9L, "operator");
@@ -71,14 +68,13 @@ class PdaPickSettlementServiceTest {
verify(splits).normalizeLoadedCartonLabels(10001L, 31L, 8);
verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3);
verify(allocations).consume(10001L, 31L, 1L, 8);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.warehouseId() == 701L && request.sourceBusinessId() == 31L
&& request.idempotencyKey().equals("PDA-SETTLE-1-31")
&& request.releasedReservationIds().isEmpty()
&& request.remainingItems().equals(List.of(new WarehousePalletLocationClient.SettlementTransferItem(91L, 2),
new WarehousePalletLocationClient.SettlementTransferItem(92L, 10)))));
verify(allocations).copyRemainingToChild(eq(10001L), anyLong(), any());
verify(allocations).markTransferred(eq(10001L), eq(31L), any(), eq(List.of()));
verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.blockId() == 800L && request.sourceBusinessId() == 31L
&& request.targetBusinessId() > 0
&& request.idempotencyKey().equals("PDA-BLOCK-1-31")));
verify(warehouse, never()).releaseBlock(anyString(), any());
verify(allocations).copyBlockToChild(eq(10001L), anyLong(), any());
verify(allocations).markBlockTransferred(10001L, 31L, 800L);
verify(splits).convergePartialPlanItem(21L, 9L, "operator");
}
@@ -92,8 +88,6 @@ class PdaPickSettlementServiceTest {
new MeasurementItemRow(52L, 61L, 41L, 1)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 2)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 1)))));
writes(1, 1);
when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(),
eq(41L), eq(61L), eq(52L), eq(1))).thenReturn(1);
@@ -114,7 +108,7 @@ class PdaPickSettlementServiceTest {
}
@Test
void reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback() {
void transfersTheWholeBlockToTheGeneratedRemainderChild() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 3, 1, "PARTIAL");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(3));
@@ -122,19 +116,16 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 3)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 3)));
when(warehouse.settlementTarget("internal-token", 31L, "PDA-SETTLE-1-31"))
.thenReturn(ApiResponse.success(999L));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 2)))));
writes(2, 1);
service.settle(1L, 9L, "operator");
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), eq(999L),
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(2),
eq(1), eq("REMAINDER"), eq(9L), eq("operator"));
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 999L && request.targetPackageCount() == 2));
verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.blockId() == 800L && request.sourceBusinessId() == 31L
&& request.targetBusinessId() > 0));
}
@Test
@@ -146,12 +137,13 @@ class PdaPickSettlementServiceTest {
service.settle(1L, 9L, "operator");
verify(warehouse, never()).settle(anyString(), any());
verify(warehouse, never()).transferBlock(anyString(), any());
verify(warehouse, never()).releaseBlock(anyString(), any());
verify(splits, never()).convergePartialPlanItem(anyLong(), anyLong(), anyString());
}
@Test
void releasesFullyPickedAllocationBeforeTransferringChildResources() {
void partialPickTransfersTheWholeBlockWithoutReleasingPickedEmptySlots() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 10, "PICKED");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
@@ -159,17 +151,13 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L)),
List.of(transfer(2, 502L, 10)))));
writes(10, 10);
service.settle(1L, 9L, "operator");
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.idempotencyKey().equals("PDA-SETTLE-1-31")
&& request.releasedReservationIds().equals(List.of(91L))
&& request.remainingItems().equals(List.of(new WarehousePalletLocationClient.SettlementTransferItem(92L, 10)))));
verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.blockId() == 800L && request.sourceBusinessId() == 31L));
verify(warehouse, never()).releaseBlock(anyString(), any());
}
@Test
@@ -184,9 +172,6 @@ class PdaPickSettlementServiceTest {
first.setPalletId(801L);
second.setPalletId(801L);
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 501L)),
List.of())));
writes(0, 20);
service.settle(1L, 9L, "operator");
@@ -196,10 +181,10 @@ class PdaPickSettlementServiceTest {
verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString());
verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 0L && request.targetPackageCount() == 0
&& request.releasedReservationIds().equals(List.of(91L, 92L))
&& request.remainingItems().isEmpty()));
verify(warehouse).releaseBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.blockId() == 800L && request.businessId() == 31L
&& request.reason().equals("ALL_PACKAGES_PICKED")));
verify(warehouse, never()).transferBlock(anyString(), any());
}
@Test
@@ -251,9 +236,6 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(
List.of(released(91L, 501L), released(92L, 502L)), List.of())));
writes(0, 20);
service.settle(1L, 9L, "operator");
@@ -272,9 +254,6 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(
List.of(released(91L, 501L), released(92L, 502L)), List.of())));
writes(0, 20);
service.settle(1L, 9L, "operator");
@@ -323,9 +302,6 @@ class PdaPickSettlementServiceTest {
stubOrderSplitSuccess();
writes(0, 20);
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(
List.of(released(91L, 501L), released(92L, 502L)), List.of())));
service.settle(1L, 9L, "operator");
@@ -341,8 +317,6 @@ class PdaPickSettlementServiceTest {
resolvedChild(item, 32L, 42L, 62L, 52L, 12, 2);
when(splits.nextSplitSequence(10001L, 31L)).thenReturn(2);
childWrites(32L, 42L, 62L, 52L, 5, 2);
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 7)))));
service.settle(1L, 9L, "operator");
@@ -365,8 +339,6 @@ class PdaPickSettlementServiceTest {
when(splits.nextSplitSequence(10001L, 31L)).thenReturn(3);
childWrites(33L, 43L, 63L, 53L, 4, 3);
when(splits.markSourceLoaded(10001L, 33L, 4, 9L, 3)).thenReturn(0, 1);
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 5)))));
assertThatThrownBy(() -> service.settle(1L, 9L, "operator"))
.hasMessageContaining("来源分片状态已被并发修改");
@@ -389,6 +361,10 @@ class PdaPickSettlementServiceTest {
task.setId(1L); task.setLoadingPlanId(701L);
when(picks.task(1L)).thenReturn(task);
when(splits.containerBookingId(701L)).thenReturn(801L);
when(warehouse.transferBlock(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(block("OCCUPIED")));
when(warehouse.releaseBlock(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(block("RELEASED")));
stubOrderSplitSuccess();
}
@@ -485,19 +461,16 @@ class PdaPickSettlementServiceTest {
private static InboundPalletAllocationEntity allocation(long id, int no, long locationId, int allocated) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setId(id); value.setAllocationNo(no); value.setLocationId(locationId); value.setWarehouseId(701L);
value.setBlockId(800L); value.setSlotNo(no); value.setAllocationStatus("CONFIRMED");
value.setReservationId(90L + no);
value.setAllocatedPackageCount(allocated); value.setPickedPackageCount(0);
return value;
}
private static WarehousePalletLocationClient.TransferResult transfer(int no, long locationId, int remaining) {
return new WarehousePalletLocationClient.TransferResult(90L + no, "RES-" + no, 92L + no, no,
0L, 701L, 801L + no, "PALLET-" + no, null, locationId, "L-" + no, null, remaining, "OCCUPIED");
}
private static WarehousePalletLocationClient.SelectiveReleaseResult released(long reservationId, long locationId) {
return new WarehousePalletLocationClient.SelectiveReleaseResult(reservationId, 92L, 1, 801L,
locationId, "L-1", 10);
private static WarehousePalletLocationClient.BlockResult block(String status) {
return new WarehousePalletLocationClient.BlockResult(800L, status, List.of(
new WarehousePalletLocationClient.BlockResource(
800L, 91L, 501L, "L-1", 1, false, status)));
}
private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId,
@@ -81,7 +81,6 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.atLeastOnce;
@SpringJUnitConfig(SmartLoadingServiceTest.Config.class)
class SmartLoadingServiceTest {
@@ -108,6 +107,7 @@ class SmartLoadingServiceTest {
when(documentNumbers.next("LP")).thenReturn("LP-260818-0001");
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
when(repository.occupiedOrderIds(any(), anySet())).thenReturn(List.of());
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
new BigDecimal("10"), new BigDecimal("2"))));
@@ -224,9 +224,9 @@ class SmartLoadingServiceTest {
100L, "EXX_TRUCK", "EXX海卡", 3, LocalDateTime.parse("2026-09-01T09:00:00"),
"PENDING_REVIEW");
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyLong(), anyLong())).thenAnswer(invocation -> {
long page = invocation.getArgument(7);
long size = invocation.getArgument(8);
isNull(), isNull(), anyList(), anyLong(), anyLong())).thenAnswer(invocation -> {
long page = invocation.getArgument(8);
long size = invocation.getArgument(9);
return ApiResponse.success(new PageResult<>(page == 1 ? List.of(pending) : List.of(),
page, size, 1, 1));
});
@@ -248,11 +248,15 @@ class SmartLoadingServiceTest {
.containsExactly(true, true);
ArgumentCaptor<InboundCandidateQuery> firstlegQueries =
ArgumentCaptor.forClass(InboundCandidateQuery.class);
verify(repository, atLeastOnce()).globalCandidateUnits(firstlegQueries.capture(), anySet());
verify(repository).globalCandidateUnits(firstlegQueries.capture(), anySet());
assertThat(firstlegQueries.getValue().page()).isEqualTo(1);
assertThat(firstlegQueries.getValue().size()).isEqualTo(19);
assertThat(firstlegQueries.getAllValues()).allSatisfy(requested ->
assertThat(requested.orderStatuses()).containsExactly(
CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.WAIT_SPLIT,
CandidateOrderStatus.LOADING));
verify(pendingReviewOrders).page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyList(), eq(1L), eq(20L));
}
@Test
@@ -311,9 +315,10 @@ class SmartLoadingServiceTest {
"EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L,
"EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"),
"PENDING_REVIEW");
when(repository.occupiedOrderIds(eq(query), anySet())).thenReturn(List.of(998L));
when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L),
isNull(), eq("EXXHK2609020361"), eq("2026-09-02"),
eq("2026-09-02"), eq(1L), eq(20L)))
eq("2026-09-02"), eq(List.of(998L)), eq(1L), eq(20L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1)));
var result = service.inboundCandidates(query);
@@ -329,26 +334,27 @@ class SmartLoadingServiceTest {
});
verify(repository, never()).globalCandidateUnits(any(), anySet());
verify(repository, never()).globalCandidatesByUnits(anyList());
verify(repository).occupiedOrderIds(eq(query), anySet());
}
@Test
void assignsPendingReviewOrderAsAProvisionalContainerAllocation() {
void manuallyAssignsCrossChannelPendingReviewOrderAsAProvisionalContainerAllocation() {
long customerId = 2092797167699816450L;
long orderId = 2095052002489335810L;
String inboundCode = "EXXHK2609020361";
var pending = new PendingReviewOrderClient.Row(orderId, "FL-361", inboundCode,
customerId, "客户", 100L, "FBA", "FBA渠道", 5,
customerId, "客户", 100L, "SLOW", "慢船", 5,
LocalDateTime.parse("2026-09-02T15:31:36"), "PENDING_REVIEW");
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
customerId, orderId, "FL-361", inboundCode, customerId, "客户", 100L, "义乌仓",
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null,
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5);
var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5,
null, null, null);
var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5,
new BigDecimal("50"), new BigDecimal("2.5"));
when(pendingReviewOrders.page(anyString(), isNull(), isNull(), eq(customerId),
eq(inboundCode), isNull(), isNull(), eq(1L), eq(2L)))
eq(inboundCode), isNull(), isNull(), isNull(), eq(1L), eq(2L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(pending), 1, 2, 1, 1)));
when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(snapshot));
@@ -381,7 +387,7 @@ class SmartLoadingServiceTest {
}
@Test
void assignsWaitMeasureInboundUsingDeclaredFactsWithoutTreatingStatusAsAGate() {
void manuallyAssignsCrossChannelWaitMeasureInboundUsingDeclaredFacts() {
long customerId = 9L;
long inboundId = 10L;
long orderId = 30L;
@@ -391,11 +397,11 @@ class SmartLoadingServiceTest {
projection.setOrderId(orderId);
projection.setInboundCode("I10");
projection.setWarehouseId(100L);
projection.setChannelCode("FBA");
projection.setChannelCode("SLOW");
projection.setProcessStatus("WAIT_MEASURE");
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
customerId, orderId, "O30", "I10", customerId, "客户", 100L, "义乌仓",
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null,
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5);
var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5,
null, null, null);
@@ -430,7 +436,7 @@ class SmartLoadingServiceTest {
List.of("EXX"), null, null, null, null, null,
List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20);
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
isNull(), isNull(), anyList(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
assertThatThrownBy(() -> service.inboundCandidates(query))
.isInstanceOf(BizException.class)
@@ -575,8 +581,8 @@ class SmartLoadingServiceTest {
@Test
void atomicallyAssignsEveryInboundFragmentOfSameOrderAcrossDifferentDestinations() {
Candidate first = candidate(9L, 10L, 20L, 30L, 4);
Candidate second = candidate(9L, 11L, 21L, 30L, 6, "FBA2");
Candidate first = withChannel(candidate(9L, 10L, 20L, 30L, 4), "SLOW", "慢船");
Candidate second = withChannel(candidate(9L, 11L, 21L, 30L, 6, "FBA2"), "SLOW", "慢船");
GroupSnapshot active = group("ACTIVE", null,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(9L, 30L, 11L, "IN-11", 6));
GroupSnapshot locked = group("LOCKED", 1L,
@@ -796,6 +802,94 @@ class SmartLoadingServiceTest {
org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u"));
}
@Test
void removesAllUnpickedItemsWithOnePlanVersionChange() {
LoadingPlanEntity initial = plan("EDITING", 2);
LoadingPlanEntity after = plan("EDITING", 3);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
LoadingPlanItemEntity second = item(9L, 19L, 21L, 6);
second.setInboundId(11L);
when(repository.plan(1L)).thenReturn(initial, after);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first, second), List.of());
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1);
Plan result = service.removeUnpicked(1L,
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "remove-unpicked"),
7L, "u");
assertThat(result.items()).isEmpty();
verify(repository).touch(1L, 2, 7L, "u");
verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"));
verify(loadingPickService, times(2)).remove(anyLong());
}
@Test
void bulkRemovalKeepsWholeGroupWhenAnyMemberWasPicked() {
LoadingPlanEntity initial = plan("LOADING", 2);
LoadingPlanEntity after = plan("LOADING", 3);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
LoadingPlanItemEntity second = item(9L, 19L, 21L, 6);
second.setInboundId(11L);
LoadingPlanItemEntity standalone = item(10L, 29L, 22L, 3);
standalone.setInboundId(12L);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(initial, after);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first, second, standalone), List.of(first, second));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.remove(1L, 10L, 0, 7L, "u")).thenReturn(1);
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 5, Map.of(8L, 1)));
Plan result = service.removeUnpicked(1L,
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "keep-picked-group"),
7L, "u");
assertThat(result.items()).extracting(LoadingPlanItemEntity::getId).containsExactly(8L, 9L);
verify(repository).remove(1L, 10L, 0, 7L, "u");
verify(repository, never()).remove(eq(1L), eq(8L), anyInt(), anyLong(), anyString());
verify(repository, never()).remove(eq(1L), eq(9L), anyInt(), anyLong(), anyString());
verify(coloadGroups, never()).unlockWholeGroupAfterRemoval(anyLong(), anyLong(), anyString(), anyLong(), anyString());
}
@Test
void bulkRemovalRemovesAndUnlocksACompletelyUnpickedGroupOnce() {
LoadingPlanEntity initial = plan("EDITING", 2);
LoadingPlanEntity after = plan("EDITING", 3);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
LoadingPlanItemEntity second = item(9L, 19L, 21L, 6);
second.setInboundId(11L);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(initial, after);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first, second), List.of());
when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first));
when(repository.activeItemsByInbound(19L, 11L)).thenReturn(List.of(second));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1);
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
GroupSnapshot unlocked = group("ACTIVE", null,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(coloadGroups.unlockWholeGroupAfterRemoval(eq(700L), eq(1L), anyString(), eq(7L), eq("u")))
.thenReturn(unlocked);
Plan result = service.removeUnpicked(1L,
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "remove-unpicked-group"),
7L, "u");
assertThat(result.items()).isEmpty();
verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"));
verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L),
org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u"));
}
@Test
void proportionallyDistributesInboundQuantityWithoutLosingCartons() {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
@@ -1319,6 +1413,44 @@ class SmartLoadingServiceTest {
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
}
@Test
void quantityAdjustmentCanDecreaseWhenCalculatedRemainingQuantityIsNegative() {
LoadingPlanEntity plan = plan("LOADING", 93);
LoadingPlanItemEntity line = item(8, 9, 20, 50);
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 94));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(-25));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 93, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
service.adjust(1L, 8L, new AdjustRequest(30, 93, 0, "adjust-down"), 7L, "u");
ArgumentCaptor<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).update(updated.capture());
assertThat(updated.getValue().getPackageCount()).isEqualTo(30);
}
@Test
void quantityAdjustmentOnlyAppliesRemainingQuantityLimitToTheIncrease() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 50);
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
service.adjust(1L, 8L, new AdjustRequest(60, 2, 0, "adjust-up"), 7L, "u");
ArgumentCaptor<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).update(updated.capture());
assertThat(updated.getValue().getPackageCount()).isEqualTo(60);
}
@Test
void unchangedQuantityAndCargoSnapshotDoNotWriteAdjustmentHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
@@ -97,11 +97,13 @@ class TaillegRealProducerAcceptanceTest {
private void seedAllocation(long id, long inboundId, long palletId, long locationId,
int packages, String suffix) {
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
"block_id,slot_no,weight_result_id,pallet_sequence,warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
"reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix,
1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix);
"shared_overflow,reservation_id,slot_status,allocation_status) " +
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'BOUND','CONFIRMED')",
id, CUSTOMER, inboundId, 1, id + 3000, 1, id + 2000, 1, 92L, 0L,
palletId, MARKER + "-PALLET-" + suffix, 1.2, 0.4, packages, packages,
locationId, "A-01", false, id + 1000);
}
private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) {
+2
View File
@@ -23,6 +23,7 @@
| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 |
| 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java` 向 Firstleg 提供箱唛生成的 SKU 快照来源;`CargoLabelBinding.skuNo` 对 `ONE_CLICK_DROPSHIP` 为强制字段。Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java`,变更两端 DTO 必须同步。 |
| 智能装柜待审核候选内部查询 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java`,契约 `src/main/resources/openapi/order-v1.yaml` |
| 快照/Outbox | `service/`、`repository/`、`event/` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` |
@@ -34,6 +35,7 @@
- `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。
- 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。
- Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。
- 智能装柜待审核候选只返回 Order 权威 `SUBMITTED` 订单,并按服务身份、客户/仓库/渠道、入仓码、创建日期和分页条件查询;Firstleg 可据此提前占位,但不得把该内部查询或申报数量当作审核、测量或可物理装柜完成的事实。
- 中文批量模板按登录客户生成取货/私人地址、国内收货仓、目的仓和渠道下拉;仓库目录必须翻页读取全部候选,不得只取第一页。导入时重新读取并校验动态目录、客户归属和启用状态,不信任模板隐藏页;文件上限 5MB、有效数据最多 50 行,仅创建草稿,`PARTIAL` 允许单行失败、`ATOMIC` 任一行失败不得落单。
- 平台只能在 `DRAFT/REJECTED/SUBMITTED/APPROVED` 版本化修改客户原始申报;不得修改客户、国内收货仓、订单号、入仓码、状态或仓库/PDA/设备实测、测量审核和计费事实,不得增删或重排货物行。国内收货仓或中转仓变化必须重新下单。
- 只有渠道发生变化才同时调用 Firstleg 履约可逆性与重新计价门禁,任一内部校验不可用或失败均不写入;成功后 Outbox 必须携带完整申报快照和字段变化。Firstleg 消费按 `eventId + payload hash` 幂等、按订单版本单调推进,渠道变化才使未确认价格预估失效。
@@ -23,6 +23,7 @@ public final class PendingReviewOrderDtos {
@Size(max = 40) String inboundCode,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdStart,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdEnd,
List<@Min(1) Long> excludedOrderIds,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
@@ -217,6 +217,7 @@ public class OrderRepository {
Long customerId, String inboundCode,
LocalDateTime createdFrom,
LocalDateTime createdBefore,
List<Long> excludedOrderIds,
long page, long size) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getDeleted, false)
@@ -227,6 +228,8 @@ public class OrderRepository {
.eq(StringUtils.hasText(inboundCode), OrderEntity::getInboundCode, inboundCode)
.ge(createdFrom != null, OrderEntity::getCreatedAt, createdFrom)
.lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore)
.notIn(excludedOrderIds != null && !excludedOrderIds.isEmpty(), OrderEntity::getId,
excludedOrderIds)
.orderByDesc(OrderEntity::getCreatedAt)
.orderByDesc(OrderEntity::getId);
return PageResult.from(orderMapper.selectPage(new Page<>(page, size), wrapper));
@@ -34,7 +34,8 @@ public class PendingReviewOrderQueryService {
LocalDateTime createdBefore = query.createdEnd() == null ? null
: query.createdEnd().plusDays(1).atStartOfDay(BUSINESS_ZONE).toLocalDateTime();
var source = orders.pendingReviewPage(channelCodes, query.warehouseId(), query.customerId(),
normalize(query.inboundCode()), createdFrom, createdBefore, query.page(), query.size());
normalize(query.inboundCode()), createdFrom, createdBefore, query.excludedOrderIds(),
query.page(), query.size());
List<Long> orderIds = source.records().stream().map(order -> order.getId()).toList();
Map<Long, Integer> packageCounts = orders.findCargoByOrderIds(null, orderIds).stream()
.collect(Collectors.groupingBy(OrderCargoEntity::getOrderId,
@@ -14,6 +14,7 @@ paths:
仅接受内部服务令牌。只返回 ord_order 中未删除且 status=SUBMITTED 的头程订单,
mainStatus 固定为 PENDING_REVIEW。createdStart/createdEnd 按 Asia/Shanghai 自然日闭区间过滤
ord_order.created_at,稳定按 created_at DESC、id DESC 分页;不读取或修复 Firstleg 投影。
excludedOrderIds 由 Firstleg 根据当前有效装柜计划提供,用于在数据库分页前排除已经占柜的订单。
parameters:
- name: X-Internal-Service-Token
in: header
@@ -25,6 +26,7 @@ paths:
- {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
- {name: createdStart, in: query, required: false, schema: {type: string, format: date}}
- {name: createdEnd, in: query, required: false, schema: {type: string, format: date}}
- {name: excludedOrderIds, in: query, required: false, style: form, explode: true, schema: {type: array, items: {type: string, pattern: '^[1-9][0-9]*$'}}}
- {name: page, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: size, in: query, required: true, schema: {type: integer, format: int64, minimum: 1, maximum: 200}}
responses:
@@ -25,7 +25,8 @@ class InternalPendingReviewOrderControllerTest {
@Test
void acceptsOnlyTheConfiguredInternalServiceToken() {
var query = new PendingReviewOrderDtos.Query(List.of("SEA-US"), null, null, null, null, null, 1, 20);
var query = new PendingReviewOrderDtos.Query(List.of("SEA-US"), null, null, null, null, null,
List.of(101L), 1, 20);
var page = new PageResult<PendingReviewOrderDtos.Row>(List.of(), 1, 20, 0, 0);
when(service.page(query)).thenReturn(page);
@@ -16,6 +16,7 @@ class PendingReviewOrderOpenApiContractTest {
.contains("channelCodes")
.contains("createdStart")
.contains("createdEnd")
.contains("excludedOrderIds")
.contains("PendingReviewOrderRow:")
.contains("mainStatus: {type: string, enum: [PENDING_REVIEW]}")
.contains("orderId: {type: string, pattern: '^[1-9][0-9]*$'}")
@@ -38,7 +38,8 @@ class PendingReviewOrderRepositoryTest {
void buildsSubmittedOnlyStablePageWithEverySupportedFilter() throws Exception {
when(orderMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>());
repository.pendingReviewPage(List.of("SEA-US", "AIR-US"), 11L, 22L, "RK-1",
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 4, 0, 0), 3, 40);
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 4, 0, 0),
List.of(101L, 102L), 3, 40);
ArgumentCaptor<Page<OrderEntity>> page = ArgumentCaptor.forClass(Page.class);
ArgumentCaptor<Wrapper<OrderEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
@@ -53,6 +54,7 @@ class PendingReviewOrderRepositoryTest {
.contains(".eq(OrderEntity::getStatus, OrderStatus.SUBMITTED)")
.contains(".in(channelCodes != null && !channelCodes.isEmpty(), OrderEntity::getChannelCode, channelCodes)")
.contains(".lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore)")
.contains(".notIn(excludedOrderIds != null && !excludedOrderIds.isEmpty(), OrderEntity::getId,")
.contains(".orderByDesc(OrderEntity::getCreatedAt)")
.contains(".orderByDesc(OrderEntity::getId)");
}
@@ -36,16 +36,16 @@ class PendingReviewOrderQueryServiceTest {
@Test
void convertsShanghaiNaturalDatesToClosedDayRangeAndNormalizesFilters() {
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
.thenReturn(new PageResult<>(List.of(), 2, 20, 0, 0));
service.page(new PendingReviewOrderDtos.Query(List.of(" SEA-US ", "SEA-US"), 11L, 22L,
" RK-1 ", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 3), 2, 20));
" RK-1 ", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 3), List.of(101L), 2, 20));
@SuppressWarnings("unchecked") ArgumentCaptor<List<String>> channels = ArgumentCaptor.forClass(List.class);
verify(repository).pendingReviewPage(channels.capture(), eq(11L), eq(22L), eq("RK-1"),
eq(LocalDateTime.of(2026, 9, 1, 0, 0)),
eq(LocalDateTime.of(2026, 9, 4, 0, 0)), eq(2L), eq(20L));
eq(LocalDateTime.of(2026, 9, 4, 0, 0)), eq(List.of(101L)), eq(2L), eq(20L));
assertThat(channels.getValue()).containsExactly("SEA-US");
}
@@ -58,11 +58,12 @@ class PendingReviewOrderQueryServiceTest {
order.setCreatedAt(LocalDateTime.of(2026, 9, 3, 10, 30));
OrderCargoEntity first = cargo(101L, 2);
OrderCargoEntity second = cargo(101L, 3);
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(repository.findCargoByOrderIds(null, List.of(101L))).thenReturn(List.of(first, second));
var result = service.page(new PendingReviewOrderDtos.Query(null, null, null, " ", null, null, 1, 20));
var result = service.page(new PendingReviewOrderDtos.Query(null, null, null, " ", null, null,
List.of(), 1, 20));
assertThat(result.records()).singleElement().satisfies(row -> {
assertThat(row.orderId()).isEqualTo(101L);
@@ -75,9 +76,9 @@ class PendingReviewOrderQueryServiceTest {
@Test
void rejectsReversedDatesAndBlankChannelValues() {
assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(null, null, null, null,
LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 3), 1, 20))).isInstanceOf(BizException.class);
LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 3), List.of(), 1, 20))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(List.of(" "), null, null,
null, null, null, 1, 20))).isInstanceOf(BizException.class);
null, null, null, List.of(), 1, 20))).isInstanceOf(BizException.class);
}
private static OrderCargoEntity cargo(long orderId, int packageCount) {
+14
View File
@@ -64,6 +64,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
|---|---|---|
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 |
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 |
| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 |
| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` |
@@ -103,6 +104,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
|---|---|---|---|
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、海外排车/出库/派送/异常/回访、财务、通知、统计等模块 |
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、多产品、海外仓、多个私人地址、地址归属失败、渠道失败、重复行、逐行隔离、失败重试、详情归属、幂等重放与冲突;只创建草稿,不提交、不审核 |
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
| 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` |
@@ -111,6 +113,18 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。
## Client 快速下单定向验收
快速下单页面使用独立批量草稿接口,不等同于 Excel 批量导入。脚本按 Client 页面当前真实请求构造最小草稿,包含页面只要求 SKU 和箱数、其余货物字段为空值或零值的行为,用于及时发现前后端契约漂移。它会使用测试客户创建 FBA、海外仓和私人地址草稿,并验证无权限请求、单行失败隔离、详情客户归属与同幂等键重放。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/client-quick-order-test-env-acceptance.mjs
```
需要额外随机创建多张草稿做压力回归时增加 `--random-count 20`。随机批次在三种目的仓类型间轮换,具体仓库/地址、每单产品数和箱数随机;逐张回查客户归属、草稿状态、目的类型和产品数量,并对整批执行幂等重放。取值范围 `1-50`,会在基础矩阵之外额外保留对应数量的草稿。
报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。
## 固定客户待审核订单
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
+1 -1
View File
@@ -172,7 +172,7 @@ node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
## 国内整柜与客户运营统计
- `domestic-container.mjs`:整柜入库、出库、库存列表和详情;静态契约为 `domestic-container-contract.test.mjs`。
- `domestic-container.mjs`:整柜入库、出库、库存列表和详情,并覆盖字段边界、组合筛选与分页汇总、柜号标准化、同柜追加入库、货名冲突、完成出库强制库存版本、并发版本竞争和幂等重放;静态契约为 `domestic-container-contract.test.mjs`。
- `customer-operating-statistics.mjs`:客户详情在途柜量/TEU 运营指标,复用 `platform:firstleg:container:list` 权限。
- 两者由总入口 `admin-test-env-acceptance.mjs` 的默认模块列表编排;接口验收会写入测试数据,静态合同可离线执行。
@@ -819,11 +819,9 @@ export async function run(context) {
const skippedInboundId = id(partialTarget.inboundId)
const partialBatchItems = (partialPda.task.items || []).map((item) => id(item.inboundId) === skippedInboundId
? { itemId: id(item.id), action: 'SKIP', pickedPackageCount: null,
finalPickedPackageCount: null, releaseMode: null,
locationIds: [], version: Number(item.version), reason: '同柜不可拆组短拣验收' }
finalPickedPackageCount: null, version: Number(item.version), reason: '同柜不可拆组短拣验收' }
: { itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
version: Number(item.version), reason: null })
finalPickedPackageCount: null, version: Number(item.version), reason: null })
const partialBatchResponse = await context.request('POST', `${PDA}/tasks/${partialPda.taskId}/batch`, {
warehouseId: first.warehouseId, loadingPlanId: partialChain.planId,
containerBookingId: partialChain.containerBookingId, expectedVersion: Number(partialPda.task.version),
@@ -863,8 +861,7 @@ export async function run(context) {
const fullTreesBefore = await fulfillmentTrees(context, fullChain.fixtures, '足量完成前')
const fullBatchItems = (fullPda.task?.items || []).map((item) => ({
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
version: Number(item.version), reason: null,
finalPickedPackageCount: null, version: Number(item.version), reason: null,
}))
const fullBatchResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/batch`, {
warehouseId: third.warehouseId, loadingPlanId: fullChain.planId,
@@ -910,8 +907,7 @@ export async function run(context) {
const normalTreeBefore = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成前')
const normalBatchItems = (normalPda.task?.items || []).map((item) => ({
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
version: Number(item.version), reason: null,
finalPickedPackageCount: null, version: Number(item.version), reason: null,
}))
const normalBatchResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/batch`, {
warehouseId: seventh.warehouseId, loadingPlanId: normalChain.planId,
@@ -28,8 +28,9 @@ test('覆盖国内仓、待单、完成入库、部分和全部出库及列表
test('在创建测试数据前覆盖无筛选整柜入库列表', () => {
const unfilteredList = '`${BASE}/inbounds?page=1&size=20`'
const runSource = moduleSource.slice(moduleSource.indexOf('export async function run(context)'))
assert.match(moduleSource, /无筛选查询整柜入库单列表/)
assert.ok(moduleSource.indexOf(unfilteredList) < moduleSource.indexOf("api(context, 'POST', `${BASE}/inbounds`"))
assert.ok(runSource.indexOf(unfilteredList) < runSource.indexOf("api(context, 'POST', `${BASE}/inbounds`"))
})
test('覆盖库存不足、版本冲突及两类幂等行为', () => {
@@ -46,3 +47,20 @@ test('覆盖权限拒绝与 requestId traceId', () => {
assert.match(moduleSource, /Boolean\(response\?\.requestId\).*Boolean\(response\?\.traceId\)/s)
assert.match(moduleSource, /status: 'DISABLED'/)
})
test('扩展覆盖字段边界、组合筛选、标准化、分页汇总和详情不存在', () => {
for (const token of ['集装箱号格式非法被拒绝', '零箱入库被拒绝', '零体积入库被拒绝',
'非法入库状态被拒绝', '空幂等键被拒绝', '超长备注被拒绝', '非法入库分页大小被拒绝',
'非法库存状态筛选被拒绝', '同仓同柜同货名追加入库', '同仓同柜不同货名禁止合并库存',
'筛选汇总不受分页大小影响且柜号已标准化', '入库单筛选总数与分页记录正确',
'不存在的整柜库存详情返回未找到']) assert.match(moduleSource, new RegExp(token))
})
test('扩展覆盖强制库存版本、并发竞争和成功请求幂等重放', () => {
assert.match(moduleSource, /完成出库必须携带库存版本/)
assert.match(moduleSource, /缺失库存版本的请求不得扣减库存/)
assert.match(moduleSource, /Promise\.all\(raceBodies\.map/)
assert.match(moduleSource, /同一库存版本并发出库仅一个成功/)
assert.match(moduleSource, /并发出库只扣减一次并只新增一条流水/)
assert.match(moduleSource, /并发成功请求重放不重复扣减/)
})
@@ -20,6 +20,102 @@ async function api(context, method, path, body, token = context.token) {
return response
}
async function verifyValidationBoundaries(context, warehouseId, marker, now) {
const valid = {
warehouseId, cargoName: marker, containerNo: `DCV${marker}`.slice(0, 32).toUpperCase(), boxCount: 1,
volumeCbm: 1, inboundTime: now, status: 'COMPLETED', remark: marker,
}
const cases = [
['集装箱号格式非法被拒绝', { ...valid, containerNo: 'bad!', idempotencyKey: `${marker}-invalid-container` }],
['零箱入库被拒绝', { ...valid, boxCount: 0, idempotencyKey: `${marker}-zero-boxes` }],
['零体积入库被拒绝', { ...valid, volumeCbm: 0, idempotencyKey: `${marker}-zero-volume` }],
['非法入库状态被拒绝', { ...valid, status: 'UNKNOWN', idempotencyKey: `${marker}-invalid-status` }],
['空幂等键被拒绝', { ...valid, idempotencyKey: ' ' }],
['超长备注被拒绝', { ...valid, remark: 'R'.repeat(501), idempotencyKey: `${marker}-long-remark` }],
]
for (const [label, body] of cases) {
const response = await api(context, 'POST', `${BASE}/inbounds`, body)
expectCode(context, label, response, 'A0422')
}
expectCode(context, '非法入库分页大小被拒绝',
await api(context, 'GET', `${BASE}/inbounds?page=1&size=201`), 'A0422')
expectCode(context, '非法库存状态筛选被拒绝',
await api(context, 'GET', `${BASE}/inventories?page=1&size=20&status=UNKNOWN`), 'A0422')
}
async function verifyExtendedInventoryScenarios(context, warehouseId, marker, now) {
const containerNo = `DCX${marker}`.slice(0, 32).toUpperCase()
const cargoName = `${marker}-extended`.slice(0, 120)
const firstBody = { warehouseId, cargoName, containerNo, boxCount: 40, volumeCbm: 20,
inboundTime: now, status: 'COMPLETED', remark: `${marker}-extended-1`, idempotencyKey: `${marker}-extended-1` }
context.apiSuccess(MODULE, '扩展场景首次完成入库', await api(context, 'POST', `${BASE}/inbounds`, firstBody))
context.apiSuccess(MODULE, '同仓同柜同货名追加入库', await api(context, 'POST', `${BASE}/inbounds`, {
...firstBody, cargoName: ` ${cargoName} `, containerNo: containerNo.toLowerCase(), boxCount: 10, volumeCbm: 5,
remark: `${marker}-extended-2`, idempotencyKey: `${marker}-extended-2`,
}))
expectCode(context, '同仓同柜不同货名禁止合并库存', await api(context, 'POST', `${BASE}/inbounds`, {
...firstBody, cargoName: `${cargoName}-other`, boxCount: 1, volumeCbm: 1,
idempotencyKey: `${marker}-cargo-conflict`,
}), 'A0409')
const filteredResponse = await api(context, 'GET', `${BASE}/inventories?page=1&size=1&warehouseId=${warehouseId}&containerNo=${containerNo.toLowerCase()}&cargoName=${encodeURIComponent(cargoName)}&status=IN_STOCK`)
const filtered = context.apiSuccess(MODULE, '组合条件筛选扩展库存', filteredResponse)
const inventory = filtered?.records?.[0]
context.assert(MODULE, '筛选汇总不受分页大小影响且柜号已标准化', filteredResponse,
Number(filtered?.total) === 1 && filtered?.records?.length === 1 && inventory?.containerNo === containerNo
&& Number(filtered?.summary?.inboundBoxes) === 50 && Number(filtered?.summary?.availableBoxes) === 50
&& Number(filtered?.summary?.inboundVolumeCbm) === 25,
`total=${filtered?.total}, records=${filtered?.records?.length}, containerNo=${inventory?.containerNo}, summary=${JSON.stringify(filtered?.summary)}`)
if (!inventory?.id) throw new Error('扩展库存未创建成功')
const inboundListResponse = await api(context, 'GET', `${BASE}/inbounds?page=1&size=1&warehouseId=${warehouseId}&containerNo=${containerNo}&cargoName=${encodeURIComponent(cargoName)}&status=COMPLETED`)
const inboundList = context.apiSuccess(MODULE, '组合条件筛选扩展入库单', inboundListResponse)
context.assert(MODULE, '入库单筛选总数与分页记录正确', inboundListResponse,
Number(inboundList?.total) === 2 && inboundList?.records?.length === 1
&& inboundList.records[0]?.status === 'COMPLETED' && inboundList.records[0]?.businessType === 'INBOUND',
`total=${inboundList?.total}, records=${inboundList?.records?.length}, status=${inboundList?.records?.[0]?.status}`)
const beforeMissingVersion = await api(context, 'GET', `${BASE}/inventories/${inventory.id}`)
const beforeDetail = context.apiSuccess(MODULE, '缺失版本测试前读取库存详情', beforeMissingVersion)
expectCode(context, '完成出库必须携带库存版本', await api(context, 'POST', `${BASE}/outbounds`, {
warehouseId, containerNo, boxCount: 1, outboundTime: now, status: 'COMPLETED',
idempotencyKey: `${marker}-missing-version`,
}), 'A0422')
const afterMissingVersion = context.apiSuccess(MODULE, '缺失版本请求后回查库存',
await api(context, 'GET', `${BASE}/inventories/${inventory.id}`))
context.assert(MODULE, '缺失库存版本的请求不得扣减库存', beforeMissingVersion,
Number(afterMissingVersion?.inventory?.availableBoxes) === Number(beforeDetail?.inventory?.availableBoxes),
`before=${beforeDetail?.inventory?.availableBoxes}, after=${afterMissingVersion?.inventory?.availableBoxes}`)
const currentVersion = Number(afterMissingVersion?.inventory?.version)
const availableBeforeRace = Number(afterMissingVersion?.inventory?.availableBoxes)
const raceBodies = [1, 2].map((sequence) => ({ warehouseId, containerNo, boxCount: 7, outboundTime: now,
status: 'COMPLETED', inventoryVersion: currentVersion, idempotencyKey: `${marker}-race-${sequence}` }))
const raceResponses = await Promise.all(raceBodies.map((body) => context.request('POST', `${BASE}/outbounds`, body, context.token)))
raceResponses.forEach((response, index) => traced(context, `并发出库请求${index + 1}`, response))
const winners = raceResponses.filter(succeeded)
const conflicts = raceResponses.filter((response) => response?.http === 200 && String(response?.body?.code) === 'A0409')
context.assert(MODULE, '同一库存版本并发出库仅一个成功', raceResponses[0], winners.length === 1 && conflicts.length === 1,
`success=${winners.length}, conflict=${conflicts.length}`)
const afterRaceResponse = await api(context, 'GET', `${BASE}/inventories/${inventory.id}`)
const afterRace = context.apiSuccess(MODULE, '并发出库后回查库存', afterRaceResponse)
context.assert(MODULE, '并发出库只扣减一次并只新增一条流水', afterRaceResponse,
Number(afterRace?.inventory?.availableBoxes) === availableBeforeRace - 7
&& afterRace?.transactions?.length === 3 && Number(afterRace?.outboundOrderCount) === 1,
`before=${availableBeforeRace}, after=${afterRace?.inventory?.availableBoxes}, transactions=${afterRace?.transactions?.length}, outboundOrders=${afterRace?.outboundOrderCount}`)
const winnerIndex = raceResponses.findIndex(succeeded)
if (winnerIndex >= 0) {
const firstWinner = raceResponses[winnerIndex]?.body?.data
const replay = await api(context, 'POST', `${BASE}/outbounds`, raceBodies[winnerIndex])
const replayData = context.apiSuccess(MODULE, '并发成功请求使用原幂等键重放', replay)
context.assert(MODULE, '并发成功请求重放不重复扣减', replay,
replayData?.replayed === true && id(replayData?.id) === id(firstWinner?.id),
`replayed=${replayData?.replayed}, firstId=${id(firstWinner?.id)}, replayId=${id(replayData?.id)}`)
}
expectCode(context, '不存在的整柜库存详情返回未找到',
await api(context, 'GET', `${BASE}/inventories/1`), 'A0404')
}
export async function run(context) {
const marker = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18)
const initialInboundsResponse = await api(context, 'GET', `${BASE}/inbounds?page=1&size=20`)
@@ -37,6 +133,8 @@ export async function run(context) {
const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase()
const now = new Date().toISOString().slice(0, 19)
await verifyValidationBoundaries(context, warehouseId, marker, now)
const invalidWarehouse = await api(context, 'POST', `${BASE}/inbounds`, {
warehouseId: '1', cargoName: marker, containerNo: `${containerNo}X`, boxCount: 1, volumeCbm: 1,
inboundTime: now, status: 'COMPLETED', remark: marker, idempotencyKey: `${marker}-invalid-warehouse`,
@@ -122,6 +220,8 @@ export async function run(context) {
inboundRows?.records?.length === 2 && outboundRows?.records?.length === 3,
`inbounds=${inboundRows?.records?.length}, outbounds=${outboundRows?.records?.length}`)
await verifyExtendedInventoryScenarios(context, warehouseId, marker, now)
await verifyPermissionDenial(context, marker, warehouseId, containerNo)
context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS }
}
@@ -290,7 +290,7 @@ async function pickAll(context, chain, label) {
expectedVersion: Number(chain.task.version), inventorySnapshotToken: snapshot.snapshotToken,
items: chain.task.items.map((item) => ({ itemId: id(item.id), action: 'PICK',
pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null,
releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), reason: null })),
version: Number(item.version), reason: null })),
}, `${label}-batch`)
chain.task = context.apiSuccess(MODULE, `${label} 完成 PDA 拣货`, response, '', OPTIONS)
return chain
@@ -240,9 +240,22 @@ export async function run(context) {
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
})
context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse,
foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)),
const foreignAssigned = foreignAssignResponse.body?.data
const foreignItem = Array.isArray(foreignAssigned?.items)
? foreignAssigned.items.find((item) => id(item.inboundId) === inboundId
&& id(item.inboundDetailId) === id(candidate.inboundDetailId))
: null
context.addStep(MODULE, '手工装柜允许两个真实有效渠道之间跨渠道分配', foreignAssignResponse,
foreignAssignResponse.http === 200 && codeOf(foreignAssignResponse) === '200' && Boolean(foreignItem?.id),
`primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
if (foreignItem?.id) {
const foreignRemoveResponse = await request(context, 'DELETE',
`${SMART}/plans/${foreignPlan?.id}/items/${foreignItem.id}`, {
expectedPlanVersion: Number(foreignAssigned.version), expectedItemVersion: Number(foreignItem.version),
operationId: `${context.marker}-other-channel-remove`.slice(0, 128),
})
context.apiSuccess(MODULE, '跨渠道分配验收后释放入仓码供主流程继续', foreignRemoveResponse, '', OPTIONS)
}
}
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
@@ -678,8 +691,7 @@ export async function run(context) {
const batchPath = `${PDA}/tasks/${taskId}/batch`
const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1,
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
version: Number(pickItem.version), reason: null }]
finalPickedPackageCount: null, version: Number(pickItem.version), reason: null }]
const staleBatchResponse = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId: planId, containerBookingId: containerId,
expectedVersion: Number(task.version) + 1,
@@ -655,7 +655,7 @@ export async function run(context) {
inventorySnapshotToken: inventory.snapshotToken,
items: [{
itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1,
releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version),
version: Number(canonicalItem.version),
}],
}
@@ -1277,7 +1277,7 @@ export async function run(context) {
expectedVersion: Number(afterUnresolvedCompleteTask?.version),
inventorySnapshotToken: partialInventory?.snapshotToken,
items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1,
releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }],
version: Number(unresolvedTaskItem?.version) }],
}, { 'Idempotency-Key': partialBatchKey })
const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS)
const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id))
@@ -1347,7 +1347,7 @@ export async function run(context) {
expectedVersion: Number(incrementedTask?.version),
inventorySnapshotToken: effectiveBatchPayload.inventorySnapshotToken,
items: [{ itemId: id(incrementalTaskItem.id), action: 'PICK', pickedPackageCount: 1,
releaseMode: 'MANUAL', locationIds: [], version: Number(incrementalTaskItem.version) }],
version: Number(incrementalTaskItem.version) }],
}, { 'Idempotency-Key': `${context.marker}-dynamic-stale-inventory`.slice(0, 128) })
context.addStep(MODULE, '增量后旧 inventory token 明确 A0409 并要求刷新', staleIncrementBatch,
staleIncrementBatch.http === 200 && codeOf(staleIncrementBatch) === 'A0409'
@@ -1424,7 +1424,7 @@ export async function run(context) {
expectedVersion: Number(refreshedDynamicTask?.version),
inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken,
items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount),
releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }],
version: Number(refreshedDynamicTaskItem?.version) }],
}, { 'Idempotency-Key': dynamicBatchKey })
const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS)
context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse,
@@ -75,7 +75,7 @@ test('each remainder settles only through inventory snapshot, atomic batch and e
assert.match(source, /expectedVersion: Number\(inventory\.taskVersion\)/)
assert.match(source, /inventorySnapshotToken: inventory\.snapshotToken/)
assert.match(source, /action: 'PICK', pickedPackageCount: 1/)
assert.match(source, /version: Number\(claimedItem\.version\), releaseMode: 'MANUAL', locationIds: \[\]/)
assert.doesNotMatch(source, /releaseMode|locationIds|reservationIds/)
assert.match(source, /'Idempotency-Key': batchKey/)
assert.match(source, /differentReplay/)
assert.match(source, /workflowStatus === 'AWAITING_REVIEW'/)
@@ -392,7 +392,7 @@ async function loadOneFragment(context, state) {
expectedVersion: Number(inventory.taskVersion), inventorySnapshotToken: inventory.snapshotToken,
items: [{
itemId: id(claimedItem.id), action: 'PICK', pickedPackageCount: 1,
version: Number(claimedItem.version), releaseMode: 'MANUAL', locationIds: [],
version: Number(claimedItem.version),
}],
}
const staleBatch = await request(context, 'POST', batchPath, {
@@ -371,12 +371,23 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch
assert.match(value, /codeOf\(foreignAssignResponse\) === 'A0422'/)
})
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
test('manual cross-channel loading fixture releases the inbound before the primary flow', () => {
const value = source('./phase3-container-loading.mjs')
assertOrder(value, [
'新增真实异渠道模拟柜',
'手工装柜允许两个真实有效渠道之间跨渠道分配',
'跨渠道分配验收后释放入仓码供主流程继续',
'人工分配本轮候选货物',
])
})
test('container loading permits manual cross-channel allocation and keeps stable PDA identity across container number changes', () => {
const value = source('./phase3-container-loading.mjs')
assertOrder(value, [
'新增内部模拟柜',
'新增真实异渠道模拟柜',
'两个真实有效渠道之间拒绝跨渠道装柜',
'手工装柜允许两个真实有效渠道之间跨渠道分配',
'跨渠道分配验收后释放入仓码供主流程继续',
'人工分配本轮候选货物',
'分配同步建立 bridge',
'无真实柜号时 PDA 查询 A0404',
+32 -2
View File
@@ -177,8 +177,8 @@ Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口'
Assert-Contains $firstlegAgents '客户四档箱规与默认算法' 'Firstleg 客户箱规入口'
Assert-Contains $firstlegAgents '`ADD/MULTIPLY`' 'Firstleg 箱规算法约束'
Assert-Contains $adminAgents '客户箱规策略与计价主数据' 'Admin 箱规计费入口'
Assert-Contains $backendAgents '全局智能装柜候选、柜次与箱数分配' '后端全局智能装柜路由'
Assert-Contains $firstlegAgents '同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界'
Assert-Contains $backendAgents '全局智能装柜候选、柜次、占位与移除' '后端全局智能装柜路由'
Assert-Contains $firstlegAgents '必须成员完整、满箱并作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界'
Assert-Contains $adminAgents 'GlobalSmartLoadingView.vue' 'Admin 全局智能装柜入口'
Assert-Contains $financeAgents 'StatementLine.inboundCode' 'Finance 账单入仓码字段'
Assert-Contains $clientAgents '账单行只显示服务端 `inboundCode`' 'Client 账单入仓码联查边界'
@@ -215,6 +215,36 @@ Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBM
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先'
# 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。
$pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java')
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
$clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts')
$clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts')
Assert-Contains $rootAgents '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
Assert-Contains $rootAgents '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
Assert-Contains $rootAgents '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口'
Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口'
Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界'
Assert-Contains $orderAgents 'PendingReviewOrderQueryService.java' 'Order 待审核装柜候选入口'
Assert-Contains $adminAgents '按钮资格以服务端 `canAssign` 为准' 'Admin 智能装柜资格边界'
Assert-Contains $firstlegOpenApi '未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、' '智能装柜四态 OpenAPI'
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:' '待审核占位 OpenAPI'
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI'
Assert-Contains $pendingReviewOrderService 'orders.pendingReviewPage' 'Order 待审核候选权威查询'
Assert-Contains $pendingReviewOrderService '"PENDING_REVIEW"' 'Order 待审核候选状态映射'
Assert-Contains $rootAgents '客户入库结果与头程费用确认' '根客户入库费用确认入口'
Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认'
Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口'
Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI'
Assert-Contains $merchantInboundBillingService 'pricing.confirmByCustomer' 'Firstleg 客户费用确认服务'
Assert-Contains $clientAgents '“记住账号”只记登录标识' 'Client 记住账号边界'
Assert-Contains $clientSession 'localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)' 'Client 刷新令牌持久化'
Assert-Contains $clientHttp "'/auth/refresh'" 'Client 公开刷新入口'
Assert-Path 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalPendingReviewOrderController.java'
Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java'
Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPanel.vue'
# 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。
$authAgents = Read-RequiredText (Join-Path $backendRoot 'auth/AGENTS.md')
$iamAgents = Read-RequiredText (Join-Path $backendRoot 'iam/AGENTS.md')
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const source = await readFile(new URL('./client-quick-order-test-env-acceptance.mjs', import.meta.url), 'utf8')
test('quick-order HTTP acceptance uses the same endpoint and skeletal cargo payload as Client', () => {
assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/batch/)
assert.match(source, /cargoes: Array\.from\(\{ length: cargoCount \}/)
assert.match(source, /cargoName: '', packageType: 'CARTON', packageCount: index \+ 1, grossWeightKg: 0, volumeCbm: 0/)
assert.match(source, /destinationType: 'FBA'/)
assert.match(source, /destinationType: 'OVERSEAS'/)
assert.match(source, /destinationType: 'PRIVATE'/)
assert.match(source, /addresses\?addressType=PRIVATE_DESTINATION/)
assert.match(source, /privateDestinationAddressId: String\(address\.id\)/)
})
test('quick-order HTTP acceptance verifies expanded row, cargo, isolation, retry, and idempotency behavior', () => {
assert.match(source, /expected succeeded=5/)
assert.match(source, /cargo count mismatch/)
assert.match(source, /unowned private address must fail closed with A0404/)
assert.match(source, /duplicate clientRowId must fail independently with A0409/)
assert.match(source, /draft must belong to the authenticated enterprise/)
assert.match(source, /same idempotency key replays the same quick-order drafts/)
assert.match(source, /idempotent replay returned different order IDs/)
assert.match(source, /same row id and idempotency key reject a changed payload/)
assert.match(source, /a corrected failed row succeeds under a fresh batch key/)
assert.match(source, /--random-count <1-50>/)
assert.match(source, /create \$\{randomCount\} randomized quick-order drafts/)
assert.match(source, /verify all \$\{randomCount\} randomized drafts/)
assert.match(source, /replay all \$\{randomCount\} randomized drafts idempotently/)
assert.match(source, /randomInt\(1, 6\)/)
})
@@ -0,0 +1,404 @@
#!/usr/bin/env node
import { randomInt, randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const args = parseArgs(process.argv.slice(2))
if (args.help) {
console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir <path>] [--timeout-ms <ms>] [--random-count <1-50>]')
process.exit(0)
}
const gateway = trimSlash(process.env.ADMIN_TEST_GATEWAY)
const username = process.env.MERCHANT_TEST_USERNAME
const password = process.env.MERCHANT_TEST_PASSWORD
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
const randomCount = Number(args['random-count'] ?? 0)
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
const marker = `${compactTimestamp()}-${randomUUID().slice(0, 8)}`
const reportPath = resolve(reportDir, `client-quick-order-test-env-${marker}.json`)
const report = {
marker,
generatedAt: new Date().toISOString(),
completedAt: null,
success: false,
fatal: null,
resources: { batchKey: null, randomBatchKey: null, orderNos: [] },
steps: [],
}
if (!gateway || !username || !password) {
console.error('ADMIN_TEST_GATEWAY, MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD are required')
process.exit(2)
}
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
console.error('--timeout-ms must be at least 1000')
process.exit(2)
}
if (!Number.isInteger(randomCount) || randomCount < 0 || randomCount > 50) {
console.error('--random-count must be an integer between 1 and 50')
process.exit(2)
}
let token
try {
await step('unauthenticated batch request is rejected', async () => {
const response = await rawRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: [], headers: { 'Idempotency-Key': `quick-order-anonymous-${marker}` },
})
assert([401, 403].includes(response.status), `expected HTTP 401/403, got ${response.status}`)
return { httpStatus: response.status }
})
const login = await step('merchant login', async () => {
const response = await jsonRequest('/api/auth/login', {
method: 'POST', body: { loginId: username, password }, authenticated: false,
})
requireSuccess(response)
assert(response.data?.accessToken, 'accessToken is required')
token = response.data.accessToken
return { authenticated: true }
})
assert(login.authenticated, 'login did not complete')
const profile = await step('load authoritative enterprise profile', async () => {
const response = await jsonRequest('/api/merchant/v1/iam/tenant/profile')
requireSuccess(response)
assert(response.data?.tenantName?.trim(), 'tenantName is required')
return { tenantId: String(response.data.id), tenantName: response.data.tenantName.trim() }
})
const policy = await step('load inbound-code policy', async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/inbound-code-policy')
requireSuccess(response)
assert(['AUTO', 'LEGACY_MANUAL'].includes(response.data?.mode), `unexpected policy ${response.data?.mode}`)
return response.data
})
let privateAddresses = []
await step('load customer-owned private destination addresses', async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/addresses?addressType=PRIVATE_DESTINATION')
requireSuccess(response)
privateAddresses = arrayData(response.data)
assert(privateAddresses.length > 0, 'at least one customer-owned private destination address is required')
return { count: privateAddresses.length }
})
const domestic = await loadWarehouses('DOMESTIC')
const fba = await loadWarehouses('FBA')
const overseas = await loadWarehouses('OVERSEAS')
assert(domestic.length > 0, 'at least one ACTIVE domestic warehouse is required')
assert(fba.length > 0, 'at least one ACTIVE FBA warehouse is required')
assert(overseas.length > 0, 'at least one ACTIVE overseas warehouse is required')
let receiving
let channel
for (const warehouse of domestic) {
const response = await jsonRequest(`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`)
requireSuccess(response)
const candidate = arrayData(response.data).find((item) => arrayData(item.pricingTaxModes).length > 0)
if (candidate) { receiving = warehouse; channel = candidate; break }
}
assert(receiving && channel, 'no domestic warehouse has an active pricing channel')
const fbaDestination = (warehouse) => ({
destinationType: 'FBA', destinationCountryCode: warehouse.countryCode, destinationWarehouseId: warehouse.id,
destinationWarehouseName: warehouse.warehouseName, amazonWarehouseCode: warehouse.warehouseCode,
})
const overseasDestination = (warehouse) => ({
destinationType: 'OVERSEAS', destinationCountryCode: warehouse.countryCode,
destinationWarehouseId: warehouse.id, destinationWarehouseName: warehouse.warehouseName,
})
const privateDestination = (address) => ({
destinationType: 'PRIVATE', destinationCountryCode: address.countryCode,
destinationWarehouseName: address.addressName, privateDestinationAddressId: String(address.id),
})
const invalidChannelRowId = `quick-order-invalid-channel-${marker}`
const invalidPrivateRowId = `quick-order-invalid-private-${marker}`
const rows = [
{ clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
fbaDestination(fba[0]), 'FBA') },
{ clientRowId: `quick-order-fba-multi-cargo-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
fbaDestination(fba[1] ?? fba[0]), 'FBA-MULTI', 3) },
{ clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
overseasDestination(overseas[1] ?? overseas[0]), 'OVERSEAS') },
{ clientRowId: `quick-order-private-a-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
privateDestination(privateAddresses[0]), 'PRIVATE-A') },
{ clientRowId: `quick-order-private-b-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
privateDestination(privateAddresses[1] ?? privateAddresses[0]), 'PRIVATE-B') },
{ clientRowId: invalidChannelRowId, draft: {
...quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'INVALID'),
channelCode: `INVALID-${marker}`, channelName: '不存在的渠道',
} },
{ clientRowId: invalidPrivateRowId, draft: quickDraft(profile.tenantName, policy, receiving, channel, {
destinationType: 'PRIVATE', destinationCountryCode: 'US', destinationWarehouseName: '越权私人仓',
privateDestinationAddressId: '9223372036854775807',
}, 'INVALID-PRIVATE') },
{ clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
fbaDestination(fba[0]), 'DUPLICATE') },
]
const expectedCargoCounts = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.cargoes.length]))
const expectedDestinationTypes = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.destinationType]))
const batchKey = `quick-order-http-${randomUUID()}`
report.resources.batchKey = batchKey
const created = await step('create five varied quick-order drafts while isolating three invalid rows', async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey },
})
requireSuccess(response)
assert(response.data?.total === 8, `expected total=8, got ${response.data?.total}`)
const rowEvidence = arrayData(response.data?.items).map((item) => ({
clientRowId: item.clientRowId, success: item.success, orderId: item.orderId, orderNo: item.orderNo,
errorCode: item.errorCode, errorMessage: item.errorMessage,
}))
assert(response.data?.succeeded === 5,
`expected succeeded=5, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`)
assert(response.data?.failed === 3,
`expected failed=3, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`)
const invalidChannel = response.data.items.find((item) => item.clientRowId === invalidChannelRowId)
const invalidPrivate = response.data.items.find((item) => item.clientRowId === invalidPrivateRowId)
const duplicate = response.data.items[7]
assert(invalidChannel && !invalidChannel.success && invalidChannel.errorCode === 'A0409',
'invalid channel row must fail independently with A0409')
assert(invalidPrivate && !invalidPrivate.success && invalidPrivate.errorCode === 'A0404',
`unowned private address must fail closed with A0404; actual=${JSON.stringify(invalidPrivate)}`)
assert(duplicate && !duplicate.success && duplicate.errorCode === 'A0409',
'duplicate clientRowId must fail independently with A0409')
return { ...response.data, items: rowEvidence }
})
const successfulItems = created.items.filter((item) => item.success)
for (const item of successfulItems) {
await step(`verify created draft ${item.clientRowId}`, async () => {
assert(item.orderId != null, 'successful row must include orderId')
assert(item.orderNo, 'successful row must include orderNo')
const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
requireSuccess(response)
assert(response.data?.order?.status === 'DRAFT', `expected DRAFT, got ${response.data?.order?.status}`)
assert(response.data?.order?.customerName === profile.tenantName, 'draft must belong to the authenticated enterprise')
assert(response.data?.order?.destinationType === expectedDestinationTypes.get(item.clientRowId),
`destination type mismatch for ${item.clientRowId}`)
assert(arrayData(response.data?.cargoes).length === expectedCargoCounts.get(item.clientRowId),
`cargo count mismatch for ${item.clientRowId}`)
report.resources.orderNos.push(item.orderNo)
return { orderNo: item.orderNo, status: response.data.order.status,
destinationType: response.data.order.destinationType, cargoCount: response.data.cargoes.length }
})
}
await step('same idempotency key replays the same quick-order drafts', async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey },
})
requireSuccess(response)
const originalIds = successfulItems.map((item) => String(item.orderId))
const replayIds = response.data.items.filter((item) => item.success).map((item) => String(item.orderId))
assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), 'idempotent replay returned different order IDs')
return { replayedOrderNos: response.data.items.filter((item) => item.success).map((item) => item.orderNo) }
})
await step('same row id and idempotency key reject a changed payload without overwriting the draft', async () => {
const changed = structuredClone(rows[0])
changed.draft.cargoes[0].packageCount = 99
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: [changed], headers: { 'Idempotency-Key': batchKey },
})
requireSuccess(response)
assert(response.data?.succeeded === 0 && response.data?.failed === 1,
'changed payload with the same idempotency key must fail')
assert(response.data?.items?.[0]?.errorCode === 'A0409',
`expected idempotency conflict A0409, got ${response.data?.items?.[0]?.errorCode}`)
return { errorCode: response.data.items[0].errorCode, errorMessage: response.data.items[0].errorMessage }
})
await step('a corrected failed row succeeds under a fresh batch key', async () => {
const corrected = {
clientRowId: invalidChannelRowId,
draft: quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'RETRY'),
}
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: [corrected], headers: { 'Idempotency-Key': `quick-order-retry-${randomUUID()}` },
})
requireSuccess(response)
const item = response.data?.items?.[0]
assert(response.data?.succeeded === 1 && item?.success && item?.orderId != null,
`corrected row did not succeed: ${JSON.stringify(response.data)}`)
const detail = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
requireSuccess(detail)
assert(detail.data?.order?.status === 'DRAFT', 'corrected row must remain a draft')
assert(detail.data?.order?.customerName === profile.tenantName,
'corrected draft must belong to the authenticated enterprise')
report.resources.orderNos.push(item.orderNo)
return { orderNo: item.orderNo, status: detail.data.order.status }
})
if (randomCount > 0) {
const destinationBuilders = [
() => fbaDestination(fba[randomInt(fba.length)]),
() => overseasDestination(overseas[randomInt(overseas.length)]),
() => privateDestination(privateAddresses[randomInt(privateAddresses.length)]),
]
const randomRows = Array.from({ length: randomCount }, (_, index) => {
const destination = destinationBuilders[index % destinationBuilders.length]()
const cargoCount = randomInt(1, 6)
const draft = quickDraft(profile.tenantName, policy, receiving, channel, destination,
`RANDOM-${index + 1}`, cargoCount)
draft.cargoes.forEach((cargo) => {
cargo.packageCount = randomInt(1, 31)
cargo.totalQuantity = cargo.packageCount * randomInt(1, 11)
cargo.unitsPerPackage = Math.max(1, Math.floor(cargo.totalQuantity / cargo.packageCount))
})
return { clientRowId: `quick-order-random-${index + 1}-${marker}`, draft }
})
const randomBatchKey = `quick-order-random-http-${randomUUID()}`
report.resources.randomBatchKey = randomBatchKey
const randomCreated = await step(`create ${randomCount} randomized quick-order drafts`, async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey },
})
requireSuccess(response)
assert(response.data?.total === randomCount, `expected random total=${randomCount}, got ${response.data?.total}`)
assert(response.data?.succeeded === randomCount && response.data?.failed === 0,
`random batch failures: ${JSON.stringify(response.data?.items?.filter((item) => !item.success))}`)
return response.data.items.map((item, index) => ({
clientRowId: item.clientRowId, orderId: item.orderId, orderNo: item.orderNo,
destinationType: randomRows[index].draft.destinationType,
cargoCount: randomRows[index].draft.cargoes.length,
}))
})
await step(`verify all ${randomCount} randomized drafts`, async () => {
const evidence = []
for (const item of randomCreated) {
const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
requireSuccess(response)
assert(response.data?.order?.status === 'DRAFT', `random draft ${item.orderNo} is not DRAFT`)
assert(response.data?.order?.customerName === profile.tenantName,
`random draft ${item.orderNo} is outside the authenticated enterprise`)
assert(response.data?.order?.destinationType === item.destinationType,
`random draft ${item.orderNo} destination type mismatch`)
assert(arrayData(response.data?.cargoes).length === item.cargoCount,
`random draft ${item.orderNo} cargo count mismatch`)
report.resources.orderNos.push(item.orderNo)
evidence.push({ orderNo: item.orderNo, destinationType: item.destinationType, cargoCount: item.cargoCount })
}
return evidence
})
await step(`replay all ${randomCount} randomized drafts idempotently`, async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey },
})
requireSuccess(response)
const originalIds = randomCreated.map((item) => String(item.orderId))
const replayIds = response.data?.items?.map((item) => String(item.orderId))
assert(JSON.stringify(replayIds) === JSON.stringify(originalIds),
'randomized idempotent replay returned different order IDs')
return { replayed: replayIds.length }
})
}
report.success = report.steps.every((item) => item.passed)
} catch (error) {
report.fatal = sanitizeError(error)
report.success = false
} finally {
report.completedAt = new Date().toISOString()
await mkdir(reportDir, { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(JSON.stringify({ success: report.success, reportPath, orderNos: report.resources.orderNos, fatal: report.fatal }))
}
process.exitCode = report.success ? 0 : 1
function quickDraft(customerName, policy, receiving, channel, destination, suffix, cargoCount = 1) {
const inboundCode = policy.mode === 'LEGACY_MANUAL' ? `QO${compactTimestamp()}${suffix.slice(0, 3)}` : undefined
return {
customerName,
transportMode: 'SEA', tradeTerm: 'FOB', expectedShipDate: undefined, customerRemark: `快速下单 HTTP 验收 ${marker} ${suffix}`,
pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: receiving.id, receivingWarehouseName: receiving.warehouseName,
channelCode: channel.channelCode, channelName: channel.channelName,
pricingTaxMode: channel.pricingTaxModes[0], taxMode: 'BUY_DECLARATION', containerMode: 'FULL_CONTAINER',
customsInspection: null, inboundCode, ...destination,
cargoes: Array.from({ length: cargoCount }, (_, index) => ({
cargoName: '', packageType: 'CARTON', packageCount: index + 1, grossWeightKg: 0, volumeCbm: 0,
currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${index + 1}-${marker}`,
totalQuantity: index + 1, unitsPerPackage: 1,
})),
}
}
async function loadWarehouses(type) {
const all = []
for (let page = 1; ; page += 1) {
const response = await jsonRequest(`/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`)
requireSuccess(response)
all.push(...pageRecords(response.data))
if (page >= Number(response.data?.pages ?? 1)) return all
}
}
async function step(name, action) {
const startedAt = new Date().toISOString()
try {
const evidence = await action()
report.steps.push({ name, passed: true, startedAt, completedAt: new Date().toISOString(), evidence })
return evidence
} catch (error) {
report.steps.push({ name, passed: false, startedAt, completedAt: new Date().toISOString(), error: sanitizeError(error) })
throw error
}
}
async function rawRequest(path, { method = 'GET', body, headers = {}, authenticated = true } = {}) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const requestHeaders = { ...headers }
if (authenticated && token) requestHeaders.Authorization = `Bearer ${token}`
let requestBody = body
if (body != null && !(body instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body)
}
try {
return await fetch(`${gateway}${path}`, { method, headers: requestHeaders, body: requestBody, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
async function jsonRequest(path, options) {
const response = await rawRequest(path, options)
const text = await response.text()
let json
try { json = text ? JSON.parse(text) : {} }
catch { throw new Error(`${path} returned non-JSON HTTP ${response.status}`) }
if (!response.ok) throw new Error(`${path} HTTP ${response.status} code=${json.code ?? '—'} requestId=${json.requestId ?? '—'} traceId=${json.traceId ?? '—'}`)
return json
}
function requireSuccess(response) {
if (String(response?.code) !== '200') {
throw new Error(`business failure code=${response?.code ?? '—'} message=${response?.message ?? '—'} requestId=${response?.requestId ?? '—'} traceId=${response?.traceId ?? '—'}`)
}
}
function parseArgs(values) {
const result = {}
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (value === '--help') { result.help = true; continue }
if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`)
const key = value.slice(2)
const next = values[++index]
if (!next || next.startsWith('--')) throw new Error(`--${key} requires a value`)
result[key] = next
}
return result
}
function trimSlash(value) { return String(value ?? '').replace(/\/+$/, '') }
function compactTimestamp() { return new Date().toISOString().replace(/\D/g, '').slice(0, 14) }
function pageRecords(value) { return value?.records ?? value?.content ?? value?.items ?? [] }
function arrayData(value) { return Array.isArray(value) ? value : [] }
function assert(condition, message) { if (!condition) throw new Error(message) }
function sanitizeError(error) { return { name: error?.name ?? 'Error', message: String(error?.message ?? error) } }
@@ -4,8 +4,10 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.LocationBlockDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.service.PalletLocationAllocationService;
import com.baiyue.warehouse.service.LocationBlockService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -31,6 +33,8 @@ import java.util.List;
public class InternalPalletLocationController {
@Resource
private PalletLocationAllocationService service;
@Resource
private LocationBlockService blockService;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@@ -49,6 +53,39 @@ public class InternalPalletLocationController {
return ApiResponse.success(service.reserve(request));
}
@PostMapping("/blocks/reservations")
@Operation(summary = "原子预占整票库位块", description = "优先同库区同前缀连续编号,普通位不足的槽位共享A0")
public ApiResponse<LocationBlockDtos.BlockResult> reserveBlock(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody LocationBlockDtos.ReserveRequest request) {
verify(token);
return ApiResponse.success(blockService.reserve(request));
}
@PostMapping("/blocks/confirm")
public ApiResponse<LocationBlockDtos.BlockResult> confirmBlock(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody LocationBlockDtos.ConfirmRequest request) {
verify(token);
return ApiResponse.success(blockService.confirm(request));
}
@PostMapping("/blocks/transfer")
public ApiResponse<LocationBlockDtos.BlockResult> transferBlock(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody LocationBlockDtos.TransferRequest request) {
verify(token);
return ApiResponse.success(blockService.transfer(request));
}
@PostMapping("/blocks/release")
public ApiResponse<LocationBlockDtos.BlockResult> releaseBlock(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody LocationBlockDtos.ReleaseRequest request) {
verify(token);
return ApiResponse.success(blockService.release(request));
}
@PostMapping("/confirm")
@Operation(summary = "确认库位占用", description = "托盘只保存主数据快照;本接口不推进头程入库单状态")
public ApiResponse<PalletAllocationDtos.BatchResult> confirm(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -81,7 +81,8 @@ public class DomesticContainerService {
DomesticContainerInventoryEntity inventory = repository.lockInventory(warehouse.getId(), normalize(request.containerNo()));
if (inventory == null) throw new BizException(ResultCode.NOT_FOUND, "整柜库存不存在");
if ("COMPLETED".equals(request.status())) {
if (request.inventoryVersion() != null && !request.inventoryVersion().equals(inventory.getVersion())) throw new BizException(ResultCode.CONFLICT, "库存已被修改,请刷新后重试");
if (request.inventoryVersion() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "完成出库必须提供库存版本");
if (!request.inventoryVersion().equals(inventory.getVersion())) throw new BizException(ResultCode.CONFLICT, "库存已被修改,请刷新后重试");
if (request.boxCount() > inventory.getAvailableBoxes()) throw new BizException(ResultCode.CONFLICT, "出库箱数超过可用库存");
}
DomesticContainerOrderEntity order = new DomesticContainerOrderEntity(); order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("OUTBOUND");
@@ -0,0 +1,70 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
/** Firstleg 可消费的整票连续库位块内部契约。 */
public final class LocationBlockDtos {
private LocationBlockDtos() {
}
public record ReserveRequest(
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@NotBlank @Size(max = 40) String inboundCode,
@Min(1) @Max(200) int requiredSlotCount,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@NotNull LocalDateTime expiresAt
) {
}
public record ConfirmRequest(
@Min(1) long blockId,
@Min(1) long businessId,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId
) {
}
public record TransferRequest(
@Min(1) long blockId,
@Min(1) long sourceBusinessId,
@Min(1) long targetBusinessId,
@NotBlank @Size(max = 40) String targetInboundCode,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId
) {
}
public record ReleaseRequest(
@Min(1) long blockId,
@Min(1) long businessId,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@NotBlank @Pattern(regexp = "^(ALL_PACKAGES_PICKED|TICKET_VOID|MANUAL_ADJUSTMENT)$") String reason
) {
}
public record Resource(
long blockId,
long reservationId,
long locationId,
String locationCode,
int slotNo,
boolean sharedOverflow,
String resourceStatus
) {
}
public record BlockResult(long blockId, String blockStatus, List<Resource> resources) {
}
}
@@ -0,0 +1,31 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_location_block")
public class LocationBlockEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private Long warehouseId;
private String sourceService;
private Long businessId;
private String inboundCode;
private Integer slotCount;
private String idempotencyKey;
private String blockStatus;
private LocalDateTime expiresAt;
private String lastOperationKey;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,27 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_location_block_slot")
public class LocationBlockSlotEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private Long blockId;
private Integer slotNo;
private Long locationId;
private String locationCode;
private Boolean sharedOverflow;
private String resourceStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,34 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.LocationBlockEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LocationBlockMapper extends BaseMapper<LocationBlockEntity> {
@Select("SELECT * FROM wh_location_block WHERE id=#{id} AND tenant_id=0 FOR UPDATE")
LocationBlockEntity lockById(@Param("id") long id);
@Update("""
UPDATE wh_location_block SET block_status=#{targetStatus}, last_operation_key=#{operationKey},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=0 AND business_id=#{businessId}
AND block_status=#{expectedStatus} AND version=#{version}
""")
int changeStatus(@Param("id") long id, @Param("businessId") long businessId,
@Param("expectedStatus") String expectedStatus, @Param("targetStatus") String targetStatus,
@Param("operationKey") String operationKey, @Param("version") int version);
@Update("""
UPDATE wh_location_block SET business_id=#{targetBusinessId}, inbound_code=#{targetInboundCode},
last_operation_key=#{operationKey}, updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=0 AND business_id=#{sourceBusinessId}
AND block_status IN ('RESERVED','OCCUPIED') AND version=#{version}
""")
int transfer(@Param("id") long id, @Param("sourceBusinessId") long sourceBusinessId,
@Param("targetBusinessId") long targetBusinessId, @Param("targetInboundCode") String targetInboundCode,
@Param("operationKey") String operationKey, @Param("version") int version);
}
@@ -0,0 +1,23 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.LocationBlockSlotEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface LocationBlockSlotMapper extends BaseMapper<LocationBlockSlotEntity> {
@Select("SELECT * FROM wh_location_block_slot WHERE tenant_id=0 AND block_id=#{blockId} ORDER BY slot_no FOR UPDATE")
List<LocationBlockSlotEntity> lockByBlockId(@Param("blockId") long blockId);
@Update("""
UPDATE wh_location_block_slot SET resource_status=#{targetStatus}, updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE tenant_id=0 AND block_id=#{blockId} AND resource_status=#{expectedStatus}
""")
int changeBlockStatus(@Param("blockId") long blockId, @Param("expectedStatus") String expectedStatus,
@Param("targetStatus") String targetStatus);
}
@@ -69,6 +69,11 @@ public interface LocationMapper extends BaseMapper<LocationEntity> {
WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id
AND o.occupancy_status = 'OCCUPIED'
)
AND NOT EXISTS (
SELECT 1 FROM wh_location_block_slot bs
WHERE bs.tenant_id = l.tenant_id AND bs.location_id = l.id
AND bs.resource_status IN ('RESERVED','OCCUPIED') AND bs.shared_overflow = 0
)
ORDER BY l.zone_code,
REGEXP_REPLACE(l.location_code, '[0-9]+$', ''),
CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED),
@@ -98,6 +103,11 @@ public interface LocationMapper extends BaseMapper<LocationEntity> {
WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id
AND o.occupancy_status = 'OCCUPIED'
)
AND NOT EXISTS (
SELECT 1 FROM wh_location_block_slot bs
WHERE bs.tenant_id = l.tenant_id AND bs.location_id = l.id
AND bs.resource_status IN ('RESERVED','OCCUPIED') AND bs.shared_overflow = 0
)
ORDER BY l.zone_code,
REGEXP_REPLACE(l.location_code, '[0-9]+$', ''),
CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED),
@@ -0,0 +1,268 @@
package com.baiyue.warehouse.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationBlockDtos;
import com.baiyue.warehouse.entity.LocationBlockEntity;
import com.baiyue.warehouse.entity.LocationBlockSlotEntity;
import com.baiyue.warehouse.entity.LocationEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.mapper.LocationBlockMapper;
import com.baiyue.warehouse.mapper.LocationBlockSlotMapper;
import com.baiyue.warehouse.repository.LocationRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** 整票库位块的唯一写入边界;块内槽位不能单独释放或转移。 */
@Service
public class LocationBlockService {
private static final long RESOURCE_TENANT_ID = 0L;
private static final int CANDIDATE_LIMIT = 1000;
private static final Pattern NUMBERED_CODE = Pattern.compile("^(.*?)(\\d+)$");
@Resource
private LocationRepository locations;
@Resource
private LocationBlockMapper blocks;
@Resource
private LocationBlockSlotMapper slots;
@Transactional
public LocationBlockDtos.BlockResult reserve(LocationBlockDtos.ReserveRequest request) {
requireActiveWarehouse(request.warehouseId());
if (!request.expiresAt().isAfter(LocalDateTime.now())) {
throw new BizException(ResultCode.BAD_REQUEST, "整票库位块过期时间必须晚于当前时间");
}
LocationBlockEntity existing = blocks.selectOne(new LambdaQueryWrapper<LocationBlockEntity>()
.eq(LocationBlockEntity::getTenantId, RESOURCE_TENANT_ID)
.eq(LocationBlockEntity::getSourceService, request.sourceService())
.eq(LocationBlockEntity::getIdempotencyKey, request.idempotencyKey()));
if (existing != null) {
requireSameReservation(existing, request);
return result(existing, slots.selectList(new LambdaQueryWrapper<LocationBlockSlotEntity>()
.eq(LocationBlockSlotEntity::getTenantId, RESOURCE_TENANT_ID)
.eq(LocationBlockSlotEntity::getBlockId, existing.getId())
.orderByAsc(LocationBlockSlotEntity::getSlotNo)));
}
List<LocationEntity> available = locations.lockAvailable(RESOURCE_TENANT_ID, request.warehouseId(), CANDIDATE_LIMIT);
List<LocationEntity> selected = selectLocations(available, request.requiredSlotCount());
LocationEntity overflow = selected.size() < request.requiredSlotCount()
? locations.lockOrCreateSharedOverflow(RESOURCE_TENANT_ID, request.warehouseId(), request.operatorId()) : null;
if (selected.size() < request.requiredSlotCount() && overflow == null) {
throw new BizException(ResultCode.CONFLICT, "系统共享溢出位 A0 创建或读取失败");
}
long blockId = IdWorker.getId();
LocationBlockEntity block = block(blockId, request);
try {
blocks.insert(block);
List<LocationBlockSlotEntity> allocated = new ArrayList<>(request.requiredSlotCount());
for (int index = 0; index < request.requiredSlotCount(); index++) {
LocationEntity location = index < selected.size() ? selected.get(index) : overflow;
if (!Boolean.TRUE.equals(location.getSharedOverflow())
&& locations.changeStatus(RESOURCE_TENANT_ID, request.warehouseId(), location.getId(),
"AVAILABLE", "RESERVED", location.getVersion(), request.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块候选被并发占用");
}
LocationBlockSlotEntity slot = slot(blockId, index + 1, location);
slots.insert(slot);
allocated.add(slot);
}
return result(block, allocated);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "整票库位块幂等键重复或普通库位已被占用");
}
}
@Transactional
public LocationBlockDtos.BlockResult confirm(LocationBlockDtos.ConfirmRequest request) {
LocationBlockEntity block = lockOwned(request.blockId(), request.businessId());
List<LocationBlockSlotEntity> allocated = slots.lockByBlockId(block.getId());
if ("OCCUPIED".equals(block.getBlockStatus()) && request.idempotencyKey().equals(block.getLastOperationKey())) {
return result(block, allocated);
}
if (!"RESERVED".equals(block.getBlockStatus()) || allocated.size() != block.getSlotCount()) {
throw new BizException(ResultCode.CONFLICT, "整票库位块不是可确认的完整预占状态");
}
for (LocationBlockSlotEntity slot : allocated) {
if (!Boolean.TRUE.equals(slot.getSharedOverflow())) {
LocationEntity location = locations.lockOwned(RESOURCE_TENANT_ID, block.getWarehouseId(), slot.getLocationId());
if (location == null || locations.changeStatus(RESOURCE_TENANT_ID, block.getWarehouseId(), location.getId(),
"RESERVED", "OCCUPIED", location.getVersion(), request.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块确认时库位状态发生并发变更");
}
}
}
if (slots.changeBlockStatus(block.getId(), "RESERVED", "OCCUPIED") != allocated.size()
|| blocks.changeStatus(block.getId(), request.businessId(), "RESERVED", "OCCUPIED",
request.idempotencyKey(), block.getVersion()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块确认时状态发生并发变更");
}
block.setBlockStatus("OCCUPIED");
block.setLastOperationKey(request.idempotencyKey());
allocated.forEach(item -> item.setResourceStatus("OCCUPIED"));
return result(block, allocated);
}
@Transactional
public LocationBlockDtos.BlockResult transfer(LocationBlockDtos.TransferRequest request) {
if (request.sourceBusinessId() == request.targetBusinessId()) {
throw new BizException(ResultCode.BAD_REQUEST, "来源与余量子入库单不能相同");
}
LocationBlockEntity block = blocks.lockById(request.blockId());
if (block != null && request.idempotencyKey().equals(block.getLastOperationKey())
&& block.getBusinessId() == request.targetBusinessId()
&& request.targetInboundCode().equals(block.getInboundCode())) {
return result(block, slots.lockByBlockId(block.getId()));
}
if (block == null || block.getBusinessId() != request.sourceBusinessId()
|| "RELEASED".equals(block.getBlockStatus())) {
throw new BizException(ResultCode.FORBIDDEN, "整票库位块不属于来源入库单或已释放");
}
if (blocks.transfer(block.getId(), request.sourceBusinessId(), request.targetBusinessId(),
request.targetInboundCode(), request.idempotencyKey(), block.getVersion()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块归属发生并发变更");
}
block.setBusinessId(request.targetBusinessId());
block.setInboundCode(request.targetInboundCode());
block.setLastOperationKey(request.idempotencyKey());
return result(block, slots.lockByBlockId(block.getId()));
}
@Transactional
public LocationBlockDtos.BlockResult release(LocationBlockDtos.ReleaseRequest request) {
LocationBlockEntity block = lockOwned(request.blockId(), request.businessId());
List<LocationBlockSlotEntity> allocated = slots.lockByBlockId(block.getId());
if ("RELEASED".equals(block.getBlockStatus()) && request.idempotencyKey().equals(block.getLastOperationKey())) {
return result(block, allocated);
}
if (!("RESERVED".equals(block.getBlockStatus()) || "OCCUPIED".equals(block.getBlockStatus()))) {
throw new BizException(ResultCode.CONFLICT, "整票库位块已释放或状态非法");
}
String previous = block.getBlockStatus();
for (LocationBlockSlotEntity slot : allocated) {
if (!Boolean.TRUE.equals(slot.getSharedOverflow())) {
LocationEntity location = locations.lockOwned(RESOURCE_TENANT_ID, block.getWarehouseId(), slot.getLocationId());
if (location == null || locations.changeStatus(RESOURCE_TENANT_ID, block.getWarehouseId(), location.getId(),
previous, "AVAILABLE", location.getVersion(), request.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块释放时库位状态发生并发变更");
}
}
}
if (slots.changeBlockStatus(block.getId(), previous, "RELEASED") != allocated.size()
|| blocks.changeStatus(block.getId(), request.businessId(), previous, "RELEASED",
request.idempotencyKey(), block.getVersion()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块释放时状态发生并发变更");
}
block.setBlockStatus("RELEASED");
block.setLastOperationKey(request.idempotencyKey());
allocated.forEach(item -> item.setResourceStatus("RELEASED"));
return result(block, allocated);
}
private void requireActiveWarehouse(long warehouseId) {
WarehouseEntity warehouse = locations.findWarehouse(RESOURCE_TENANT_ID, warehouseId);
if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "可用平台公共仓库不存在");
}
}
private LocationBlockEntity lockOwned(long blockId, long businessId) {
LocationBlockEntity block = blocks.lockById(blockId);
if (block == null || block.getBusinessId() != businessId) {
throw new BizException(ResultCode.FORBIDDEN, "整票库位块不属于指定入库单");
}
return block;
}
private static List<LocationEntity> selectLocations(List<LocationEntity> available, int required) {
if (available.size() < required) {
return List.copyOf(available);
}
for (int start = 0; start <= available.size() - required; start++) {
List<LocationEntity> run = available.subList(start, start + required);
if (isContinuous(run)) {
return List.copyOf(run);
}
}
return List.copyOf(available.subList(0, required));
}
private static boolean isContinuous(List<LocationEntity> run) {
Matcher first = NUMBERED_CODE.matcher(run.get(0).getLocationCode());
if (!first.matches()) {
return false;
}
String zone = run.get(0).getZoneCode();
String prefix = first.group(1);
int previous = Integer.parseInt(first.group(2));
for (int index = 1; index < run.size(); index++) {
LocationEntity item = run.get(index);
Matcher current = NUMBERED_CODE.matcher(item.getLocationCode());
if (!current.matches() || !Objects.equals(zone, item.getZoneCode()) || !prefix.equals(current.group(1))
|| Integer.parseInt(current.group(2)) != previous + 1) {
return false;
}
previous++;
}
return true;
}
private static LocationBlockEntity block(long id, LocationBlockDtos.ReserveRequest request) {
LocationBlockEntity entity = new LocationBlockEntity();
entity.setId(id);
entity.setTenantId(RESOURCE_TENANT_ID);
entity.setWarehouseId(request.warehouseId());
entity.setSourceService(request.sourceService());
entity.setBusinessId(request.businessId());
entity.setInboundCode(request.inboundCode());
entity.setSlotCount(request.requiredSlotCount());
entity.setIdempotencyKey(request.idempotencyKey());
entity.setBlockStatus("RESERVED");
entity.setExpiresAt(request.expiresAt());
entity.setCreatedBy(request.operatorId());
entity.setVersion(0);
return entity;
}
private static LocationBlockSlotEntity slot(long blockId, int slotNo, LocationEntity location) {
LocationBlockSlotEntity entity = new LocationBlockSlotEntity();
entity.setId(IdWorker.getId());
entity.setTenantId(RESOURCE_TENANT_ID);
entity.setBlockId(blockId);
entity.setSlotNo(slotNo);
entity.setLocationId(location.getId());
entity.setLocationCode(location.getLocationCode());
entity.setSharedOverflow(Boolean.TRUE.equals(location.getSharedOverflow()));
entity.setResourceStatus("RESERVED");
entity.setVersion(0);
return entity;
}
private static void requireSameReservation(LocationBlockEntity block, LocationBlockDtos.ReserveRequest request) {
if (block.getWarehouseId() != request.warehouseId() || block.getBusinessId() != request.businessId()
|| block.getSlotCount() != request.requiredSlotCount() || !block.getInboundCode().equals(request.inboundCode())) {
throw new BizException(ResultCode.CONFLICT, "整票库位块幂等键已用于不同请求");
}
}
private static LocationBlockDtos.BlockResult result(LocationBlockEntity block, List<LocationBlockSlotEntity> slots) {
return new LocationBlockDtos.BlockResult(block.getId(), block.getBlockStatus(), slots.stream()
.map(item -> new LocationBlockDtos.Resource(block.getId(), item.getId(), item.getLocationId(),
item.getLocationCode(), item.getSlotNo(), Boolean.TRUE.equals(item.getSharedOverflow()),
item.getResourceStatus()))
.toList());
}
}
@@ -133,4 +133,54 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy (
CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0)
) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照';
CREATE TABLE IF NOT EXISTS wh_location_block (
id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共仓储资源域,固定为0',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '当前归属入库单业务ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '当前归属入仓码',
slot_count INT UNSIGNED NOT NULL COMMENT '整票独占槽位数',
idempotency_key VARCHAR(128) NOT NULL COMMENT '预占幂等键',
block_status VARCHAR(20) NOT NULL COMMENT 'RESERVED、OCCUPIED或RELEASED',
expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间',
last_operation_key VARCHAR(128) NULL COMMENT '最近确认、转移或释放幂等键',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_business_block TINYINT GENERATED ALWAYS AS
(CASE WHEN block_status IN ('RESERVED','OCCUPIED') THEN 1 ELSE NULL END) STORED
COMMENT '同一来源入库单仅允许一个活动整票块',
PRIMARY KEY (id),
UNIQUE KEY uk_location_block_idempotency (tenant_id, source_service, idempotency_key),
UNIQUE KEY uk_location_block_active_business (tenant_id, source_service, business_id, active_business_block),
KEY idx_location_block_business (tenant_id, source_service, business_id, block_status),
CONSTRAINT fk_location_block_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_location_block_slot_count CHECK (slot_count BETWEEN 1 AND 200)
) ENGINE=InnoDB COMMENT='一票入仓码原子持有的稳定库位块';
CREATE TABLE IF NOT EXISTS wh_location_block_slot (
id BIGINT UNSIGNED NOT NULL COMMENT '槽位预占ID,对外作为reservationId',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共仓储资源域,固定为0',
block_id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID',
slot_no INT UNSIGNED NOT NULL COMMENT '块内稳定槽位序号,从1开始',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否落入同仓共享A0',
resource_status VARCHAR(20) NOT NULL COMMENT 'RESERVED、OCCUPIED或RELEASED',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_normal_slot TINYINT GENERATED ALWAYS AS
(CASE WHEN resource_status IN ('RESERVED','OCCUPIED') AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED
COMMENT '普通库位活动块槽唯一标记;A0返回NULL',
PRIMARY KEY (id),
UNIQUE KEY uk_location_block_slot_no (tenant_id, block_id, slot_no),
UNIQUE KEY uk_location_block_active_normal (tenant_id, location_id, active_normal_slot),
KEY idx_location_block_slot_block (tenant_id, block_id, resource_status),
CONSTRAINT fk_location_block_slot_block FOREIGN KEY (block_id) REFERENCES wh_location_block (id),
CONSTRAINT fk_location_block_slot_location FOREIGN KEY (location_id) REFERENCES wh_location (id)
) ENGINE=InnoDB COMMENT='整票库位块槽位;空槽也由当前入仓码独占';
-- 头程入库单属于其他数据库,不建立跨库外键。
@@ -74,7 +74,7 @@ components:
status: { type: string, enum: [PENDING, COMPLETED] }
remark: { type: string, maxLength: 500 }
idempotencyKey: { type: string, maxLength: 128 }
inventoryVersion: { type: integer, minimum: 0 }
inventoryVersion: { type: integer, minimum: 0, description: 完成出库时必填;待出库不扣减库存时可不传 }
ApiEnvelope:
type: object
required: [code, message, timestamp]
@@ -0,0 +1,122 @@
openapi: 3.0.3
info:
title: Warehouse Internal Location Block API
version: 1.0.0
paths:
/internal/warehouse/v1/pallet-locations/blocks/reservations:
post:
summary: 原子预占整票库位块
security: [{ internalServiceToken: [] }]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ReserveRequest' }
responses:
'200': { $ref: '#/components/responses/BlockResponse' }
/internal/warehouse/v1/pallet-locations/blocks/confirm:
post:
summary: 整段确认占用
security: [{ internalServiceToken: [] }]
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/ConfirmRequest' } } }
responses:
'200': { $ref: '#/components/responses/BlockResponse' }
/internal/warehouse/v1/pallet-locations/blocks/transfer:
post:
summary: 将整段资源归属转移给余量子入库单
security: [{ internalServiceToken: [] }]
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/TransferRequest' } } }
responses:
'200': { $ref: '#/components/responses/BlockResponse' }
/internal/warehouse/v1/pallet-locations/blocks/release:
post:
summary: 整段释放
description: 仅整票剩余箱数为0、整票作废或显式人工调整可调用;不提供单槽释放接口。
security: [{ internalServiceToken: [] }]
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/ReleaseRequest' } } }
responses:
'200': { $ref: '#/components/responses/BlockResponse' }
components:
securitySchemes:
internalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
responses:
BlockResponse:
description: 统一 ApiResponse,data 为整票块快照
content:
application/json:
schema:
type: object
required: [code, message, data]
properties:
code: { type: string }
message: { type: string }
data: { $ref: '#/components/schemas/BlockResult' }
schemas:
ReserveRequest:
type: object
required: [warehouseId, sourceService, businessId, inboundCode, requiredSlotCount, idempotencyKey, operatorId, expiresAt]
properties:
warehouseId: { type: integer, format: int64, minimum: 1 }
sourceService: { type: string, maxLength: 40 }
businessId: { type: integer, format: int64, minimum: 1 }
inboundCode: { type: string, maxLength: 40 }
requiredSlotCount: { type: integer, minimum: 1, maximum: 200 }
idempotencyKey: { type: string, maxLength: 128 }
operatorId: { type: integer, format: int64, minimum: 1 }
expiresAt: { type: string, format: date-time }
ConfirmRequest:
type: object
required: [blockId, businessId, idempotencyKey, operatorId]
properties:
blockId: { type: integer, format: int64, minimum: 1 }
businessId: { type: integer, format: int64, minimum: 1 }
idempotencyKey: { type: string, maxLength: 128 }
operatorId: { type: integer, format: int64, minimum: 1 }
TransferRequest:
type: object
required: [blockId, sourceBusinessId, targetBusinessId, targetInboundCode, idempotencyKey, operatorId]
properties:
blockId: { type: integer, format: int64, minimum: 1 }
sourceBusinessId: { type: integer, format: int64, minimum: 1 }
targetBusinessId: { type: integer, format: int64, minimum: 1 }
targetInboundCode: { type: string, maxLength: 40 }
idempotencyKey: { type: string, maxLength: 128 }
operatorId: { type: integer, format: int64, minimum: 1 }
ReleaseRequest:
type: object
required: [blockId, businessId, idempotencyKey, operatorId, reason]
properties:
blockId: { type: integer, format: int64, minimum: 1 }
businessId: { type: integer, format: int64, minimum: 1 }
idempotencyKey: { type: string, maxLength: 128 }
operatorId: { type: integer, format: int64, minimum: 1 }
reason: { type: string, enum: [ALL_PACKAGES_PICKED, TICKET_VOID, MANUAL_ADJUSTMENT] }
BlockResult:
type: object
required: [blockId, blockStatus, resources]
properties:
blockId: { type: integer, format: int64 }
blockStatus: { type: string, enum: [RESERVED, OCCUPIED, RELEASED] }
resources:
type: array
items: { $ref: '#/components/schemas/Resource' }
Resource:
type: object
required: [blockId, reservationId, locationId, locationCode, slotNo, sharedOverflow, resourceStatus]
properties:
blockId: { type: integer, format: int64 }
reservationId: { type: integer, format: int64 }
locationId: { type: integer, format: int64 }
locationCode: { type: string }
slotNo: { type: integer, minimum: 1 }
sharedOverflow: { type: boolean }
resourceStatus: { type: string, enum: [RESERVED, OCCUPIED, RELEASED] }
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.LocationBlockDtos;
import com.baiyue.warehouse.service.PalletLocationAllocationService;
import com.baiyue.warehouse.service.LocationBlockService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -22,11 +24,13 @@ class InternalPalletLocationControllerTest {
private static final String INTERNAL_TOKEN = "test-internal-service-token";
private final PalletLocationAllocationService service = mock(PalletLocationAllocationService.class);
private final LocationBlockService blockService = mock(LocationBlockService.class);
private final InternalPalletLocationController controller = new InternalPalletLocationController();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(controller, "service", service);
ReflectionTestUtils.setField(controller, "blockService", blockService);
ReflectionTestUtils.setField(controller, "internalToken", INTERNAL_TOKEN);
controller.initializeToken();
}
@@ -78,6 +82,22 @@ class InternalPalletLocationControllerTest {
verify(service).occupiedResources(0L, "firstleg-service", 88L);
}
@Test
void reservesWholeBlockOnlyForInternalCaller() {
LocationBlockDtos.ReserveRequest request = new LocationBlockDtos.ReserveRequest(20L, "firstleg-service",
88L, "RK-001", 3, "reserve-block-1", 9001L, java.time.LocalDateTime.now().plusHours(1));
LocationBlockDtos.BlockResult expected = new LocationBlockDtos.BlockResult(700L, "RESERVED", List.of(
new LocationBlockDtos.Resource(700L, 701L, 40L, "A01", 1, false, "RESERVED")));
when(blockService.reserve(request)).thenReturn(expected);
assertThat(controller.reserveBlock(INTERNAL_TOKEN, request).data()).isEqualTo(expected);
verify(blockService).reserve(request);
assertThatThrownBy(() -> controller.reserveBlock("invalid", request))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
}
private static PalletAllocationDtos.TransferRequest request() {
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L,
"RK-001-S1", "transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5)));
@@ -1,6 +1,7 @@
package com.baiyue.warehouse.domesticcontainer.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.warehouse.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerIdempotencyEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerInventoryEntity;
@@ -76,6 +77,24 @@ class DomesticContainerServiceTest {
verify(repository, never()).insertOrder(any());
}
@Test
void completedOutboundRequiresInventoryVersionBeforeWriting() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("OUTBOUND"), eq("out-no-version"), any())).thenReturn(1);
DomesticContainerInventoryEntity inventory = inventory(120, 0, 120, 3);
when(repository.lockInventory(10L, "MSCU1234567")).thenReturn(inventory);
assertThatThrownBy(() -> service.createOutbound(new DomesticContainerDtos.CreateOutboundRequest(10L,
"MSCU1234567", 1, LocalDateTime.now(), "COMPLETED", null, "out-no-version", null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(inventory.getAvailableBoxes()).isEqualTo(120);
verify(repository, never()).insertOrder(any());
verify(repository, never()).updateInventory(any());
verify(repository, never()).insertTransaction(any());
}
@Test
void replaysCompletedIdempotentRequestWithoutWriting() {
DomesticContainerIdempotencyEntity replay = new DomesticContainerIdempotencyEntity();
@@ -47,6 +47,13 @@ class WarehouseSchemaContractTest {
@Test
void shouldHandleCase04() throws IOException {
String sql = read("phase2-pallet-location-schema.sql");
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_location_block (")
.contains("CREATE TABLE IF NOT EXISTS wh_location_block_slot (")
.contains("UNIQUE KEY uk_location_block_active_normal")
.contains("UNIQUE KEY uk_location_block_active_business")
.contains("block_status IN ('RESERVED','OCCUPIED')")
.contains("resource_status IN ('RESERVED','OCCUPIED')")
.contains("COMMENT='整票库位块槽位;空槽也由当前入仓码独占'");
assertColumns(sql, "wh_pallet", "tenant_id", "warehouse_id", "pallet_code", "pallet_type",
"capacity_cbm", "weight_kg", "color", "remark", "deleted", "version");
assertThat(tableDefinition(sql, "wh_pallet"))
@@ -0,0 +1,176 @@
package com.baiyue.warehouse.service;
import com.baiyue.warehouse.dto.LocationBlockDtos;
import com.baiyue.warehouse.entity.LocationBlockEntity;
import com.baiyue.warehouse.entity.LocationBlockSlotEntity;
import com.baiyue.warehouse.entity.LocationEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.mapper.LocationBlockMapper;
import com.baiyue.warehouse.mapper.LocationBlockSlotMapper;
import com.baiyue.warehouse.repository.LocationRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LocationBlockServiceTest {
private final LocationRepository locations = mock(LocationRepository.class);
private final LocationBlockMapper blocks = mock(LocationBlockMapper.class);
private final LocationBlockSlotMapper slots = mock(LocationBlockSlotMapper.class);
private final LocationBlockService service = new LocationBlockService();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "locations", locations);
ReflectionTestUtils.setField(service, "blocks", blocks);
ReflectionTestUtils.setField(service, "slots", slots);
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(20L);
warehouse.setTenantId(0L);
warehouse.setStatus("ACTIVE");
when(locations.findWarehouse(0L, 20L)).thenReturn(warehouse);
when(blocks.selectOne(any())).thenReturn(null);
when(blocks.insert(any(LocationBlockEntity.class))).thenReturn(1);
when(slots.insert(any(LocationBlockSlotEntity.class))).thenReturn(1);
when(locations.changeStatus(anyLong(), anyLong(), anyLong(), any(), any(), anyInt(), anyLong())).thenReturn(1);
}
@Test
void reservesSameZoneAndPrefixContinuousRunAheadOfEarlierGap() {
when(locations.lockAvailable(0L, 20L, 1000)).thenReturn(List.of(
location(1L, "A", "A01"), location(2L, "A", "A03"),
location(3L, "B", "B01"), location(4L, "B", "B02"), location(5L, "B", "B03")));
LocationBlockDtos.BlockResult result = service.reserve(request(3));
assertThat(result.blockStatus()).isEqualTo("RESERVED");
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::locationCode)
.containsExactly("B01", "B02", "B03");
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::slotNo).containsExactly(1, 2, 3);
assertThat(result.resources()).allSatisfy(item -> {
assertThat(item.blockId()).isEqualTo(result.blockId());
assertThat(item.sharedOverflow()).isFalse();
assertThat(item.resourceStatus()).isEqualTo("RESERVED");
});
verify(locations, never()).lockOrCreateSharedOverflow(anyLong(), anyLong(), anyLong());
}
@Test
void keepsAllOrdinaryLocationsAndUsesSharedA0ForOnlyTheMissingSlots() {
LocationEntity ordinary = location(1L, "A", "A01");
LocationEntity overflow = location(99L, "OVERFLOW", "A0");
overflow.setSharedOverflow(true);
when(locations.lockAvailable(0L, 20L, 1000)).thenReturn(List.of(ordinary));
when(locations.lockOrCreateSharedOverflow(0L, 20L, 9001L)).thenReturn(overflow);
LocationBlockDtos.BlockResult result = service.reserve(request(3));
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::locationCode)
.containsExactly("A01", "A0", "A0");
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::sharedOverflow)
.containsExactly(false, true, true);
verify(locations).changeStatus(0L, 20L, 1L, "AVAILABLE", "RESERVED", 0, 9001L);
verify(locations, never()).changeStatus(eq(0L), eq(20L), eq(99L), any(), any(), anyInt(), anyLong());
}
@Test
void transfersOwnershipWithoutReleasingAnySlot() {
LocationBlockEntity block = block("OCCUPIED", 88L, null);
when(blocks.lockById(700L)).thenReturn(block);
when(blocks.transfer(700L, 88L, 89L, "RK-S1", "transfer-1", 0)).thenReturn(1);
when(slots.lockByBlockId(700L)).thenReturn(List.of(slot(701L, 1, 1L, "A01", false, "OCCUPIED")));
LocationBlockDtos.BlockResult result = service.transfer(
new LocationBlockDtos.TransferRequest(700L, 88L, 89L, "RK-S1", "transfer-1", 9001L));
assertThat(result.blockStatus()).isEqualTo("OCCUPIED");
verify(blocks).transfer(700L, 88L, 89L, "RK-S1", "transfer-1", 0);
verify(slots, never()).changeBlockStatus(anyLong(), any(), any());
verify(locations, never()).changeStatus(anyLong(), anyLong(), anyLong(), any(), any(), anyInt(), anyLong());
}
@Test
void releasesEveryNormalSlotTogetherAndLeavesSharedA0Available() {
LocationBlockEntity block = block("OCCUPIED", 88L, null);
LocationBlockSlotEntity first = slot(701L, 1, 1L, "A01", false, "OCCUPIED");
LocationBlockSlotEntity overflow = slot(702L, 2, 99L, "A0", true, "OCCUPIED");
when(blocks.lockById(700L)).thenReturn(block);
when(slots.lockByBlockId(700L)).thenReturn(List.of(first, overflow));
when(locations.lockOwned(0L, 20L, 1L)).thenReturn(location(1L, "A", "A01", "OCCUPIED"));
when(slots.changeBlockStatus(700L, "OCCUPIED", "RELEASED")).thenReturn(2);
when(blocks.changeStatus(700L, 88L, "OCCUPIED", "RELEASED", "release-1", 0)).thenReturn(1);
LocationBlockDtos.BlockResult result = service.release(new LocationBlockDtos.ReleaseRequest(
700L, 88L, "release-1", 9001L, "ALL_PACKAGES_PICKED"));
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::resourceStatus)
.containsOnly("RELEASED");
verify(locations).changeStatus(0L, 20L, 1L, "OCCUPIED", "AVAILABLE", 0, 9001L);
verify(locations, never()).lockOwned(0L, 20L, 99L);
}
private static LocationBlockDtos.ReserveRequest request(int slots) {
return new LocationBlockDtos.ReserveRequest(20L, "firstleg-service", 88L, "RK-001", slots,
"reserve-1", 9001L, LocalDateTime.now().plusHours(1));
}
private static LocationEntity location(long id, String zone, String code) {
return location(id, zone, code, "AVAILABLE");
}
private static LocationEntity location(long id, String zone, String code, String status) {
LocationEntity entity = new LocationEntity();
entity.setId(id);
entity.setTenantId(0L);
entity.setWarehouseId(20L);
entity.setZoneCode(zone);
entity.setLocationCode(code);
entity.setSharedOverflow(false);
entity.setStatus(status);
entity.setVersion(0);
return entity;
}
private static LocationBlockEntity block(String status, long businessId, String operationKey) {
LocationBlockEntity entity = new LocationBlockEntity();
entity.setId(700L);
entity.setTenantId(0L);
entity.setWarehouseId(20L);
entity.setSourceService("firstleg-service");
entity.setBusinessId(businessId);
entity.setInboundCode("RK-001");
entity.setSlotCount(2);
entity.setBlockStatus(status);
entity.setLastOperationKey(operationKey);
entity.setVersion(0);
return entity;
}
private static LocationBlockSlotEntity slot(long id, int slotNo, long locationId, String code,
boolean overflow, String status) {
LocationBlockSlotEntity entity = new LocationBlockSlotEntity();
entity.setId(id);
entity.setTenantId(0L);
entity.setBlockId(700L);
entity.setSlotNo(slotNo);
entity.setLocationId(locationId);
entity.setLocationCode(code);
entity.setSharedOverflow(overflow);
entity.setResourceStatus(status);
entity.setVersion(0);
return entity;
}
}