merge: integrate TEST updates and verify appointment release

This commit is contained in:
2026-10-15 17:08:30 +08:00
72 changed files with 2804 additions and 164 deletions
+2
View File
@@ -155,3 +155,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。 `migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。
自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。 自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
@@ -0,0 +1,21 @@
-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。
-- 自定义客户角色由管理员按需分配;不自动扩大其权限。
-- 可重复执行;授权和持久化安全版本在同一事务提交。
START TRANSACTION;
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0;
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
JOIN iam_role r ON r.id=mr.role_id
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT'
AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
COMMIT;
@@ -0,0 +1,24 @@
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,13 @@
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
-- 原子替换约束,可重复执行;不修改业务记录。
SET @ddl = IF(EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
),
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,3 @@
-- 客户确认废弃完结时间,可重复执行;仅结构变更。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -7142,7 +7142,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306183,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3));
+64 -3
View File
@@ -48,6 +48,7 @@ CREATE TABLE ll_cargo_discard_carton (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
carton_id bigint NOT NULL COMMENT '箱唛记录ID', carton_id bigint NOT NULL COMMENT '箱唛记录ID',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛', carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_discard_carton (application_id,carton_id), UNIQUE KEY uk_ll_cargo_discard_carton (application_id,carton_id),
@@ -60,13 +61,16 @@ CREATE TABLE ll_cargo_discard_item (
application_id bigint NOT NULL COMMENT '申请单ID', application_id bigint NOT NULL COMMENT '申请单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
order_id bigint DEFAULT NULL COMMENT '一件代发订单ID', order_id bigint DEFAULT NULL COMMENT '一件代发订单ID',
source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON', source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
return_carton_id bigint DEFAULT NULL COMMENT '退货库存箱ID', return_carton_id bigint DEFAULT NULL COMMENT '退货库存箱ID',
inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照',
profile_id bigint DEFAULT NULL COMMENT '托盘档案ID', profile_id bigint DEFAULT NULL COMMENT '托盘档案ID',
sku_inventory_id bigint DEFAULT NULL COMMENT 'SKU库存记录ID', sku_inventory_id bigint DEFAULT NULL COMMENT 'SKU库存记录ID',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称', product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
available_packages_snapshot int NOT NULL COMMENT '可用箱数快照', available_packages_snapshot int NOT NULL COMMENT '可用箱数快照',
discard_packages int NOT NULL COMMENT '申请废弃箱数', discard_packages int NOT NULL COMMENT '申请废弃箱数',
completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组', completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组',
@@ -77,6 +81,7 @@ CREATE TABLE ll_cargo_discard_item (
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_discard_item (application_id,profile_id,sku_code), UNIQUE KEY uk_ll_cargo_discard_item (application_id,profile_id,sku_code),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list (customer_id,application_id), KEY idx_ll_cargo_discard_item_list (customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound (customer_id,inbound_code,sku_code) KEY idx_ll_cargo_discard_item_inbound (customer_id,inbound_code,sku_code)
,KEY idx_ll_cargo_discard_return_carton (customer_id,return_carton_id) ,KEY idx_ll_cargo_discard_return_carton (customer_id,return_carton_id)
@@ -1098,7 +1103,7 @@ CREATE TABLE ll_inbound_plan_carton (
warehouse_pallet_id bigint DEFAULT NULL COMMENT '海外实物托盘ID', warehouse_pallet_id bigint DEFAULT NULL COMMENT '海外实物托盘ID',
warehouse_location_id bigint DEFAULT NULL COMMENT '海外库位ID', warehouse_location_id bigint DEFAULT NULL COMMENT '海外库位ID',
pda_task_id bigint DEFAULT NULL COMMENT '首次扫码PDA任务ID', pda_task_id bigint DEFAULT NULL COMMENT '首次扫码PDA任务ID',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED',
version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本',
bound_at datetime(3) DEFAULT NULL COMMENT '绑定托盘时间', bound_at datetime(3) DEFAULT NULL COMMENT '绑定托盘时间',
received_at datetime(3) DEFAULT NULL COMMENT '完成入库时间', received_at datetime(3) DEFAULT NULL COMMENT '完成入库时间',
@@ -2494,7 +2499,7 @@ CREATE TABLE ll_return_inventory_carton_item (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code), UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity >= 0)),
packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数' packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
@@ -4347,6 +4352,31 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE ll_selfop_owner ( CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键', id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本', run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
@@ -4382,6 +4412,37 @@ CREATE TABLE ll_selfop_product (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
USE baiyue_lastleg; USE baiyue_lastleg;
-- 历史库存基线;执行前必须取得指定环境的建表授权。 -- 历史库存基线;执行前必须取得指定环境的建表授权。
@@ -51,7 +51,8 @@ class TerminalDrayageOpenApiContractTest {
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays", "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情", "船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件"); "允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
assertThat(yaml).contains("description: 卸柜完成时间加72小时"); assertThat(yaml).contains("原海外仓卸柜时间加72小时", "直送按最晚真实卸货Instant加72小时",
"直送72小时按绝对时长计算", "不比较不同地点的当地钟面值");
assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
"platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP"); "platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP");
} }
@@ -142,6 +142,7 @@ VALUES
(5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0), (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0),
(5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0), (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0),
(5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0), (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0),
(5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0),
(5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0), (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0),
(5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0), (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0),
(5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0) (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0)
@@ -336,6 +337,7 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code
'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:create',
'merchant:overseas-warehouse:discard:cancel', 'merchant:overseas-warehouse:discard:cancel',
'merchant:overseas-warehouse:discard:confirm',
'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:list',
'merchant:overseas-warehouse:return-order:create', 'merchant:overseas-warehouse:return-order:create',
'merchant:overseas-warehouse:return-order:relabel' 'merchant:overseas-warehouse:return-order:relabel'
@@ -371,9 +373,10 @@ BEGIN
'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:create',
'merchant:overseas-warehouse:discard:cancel', 'merchant:overseas-warehouse:discard:cancel',
'merchant:overseas-warehouse:discard:confirm',
'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:list',
'merchant:overseas-warehouse:return-order:create', 'merchant:overseas-warehouse:return-order:create',
'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN 'merchant:overseas-warehouse:return-order:relabel')) <> 25 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row IF (SELECT COUNT(*) FROM iam_role_permission grant_row
@@ -405,9 +408,10 @@ BEGIN
'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:create',
'merchant:overseas-warehouse:discard:cancel', 'merchant:overseas-warehouse:discard:cancel',
'merchant:overseas-warehouse:discard:confirm',
'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:list',
'merchant:overseas-warehouse:return-order:create', 'merchant:overseas-warehouse:return-order:create',
'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN 'merchant:overseas-warehouse:return-order:relabel')) <> 25 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整';
END IF; END IF;
END$$ END$$
@@ -20,7 +20,7 @@ class MerchantReturnInventoryPermissionSqlContractTest {
assertThat(sql).contains( assertThat(sql).contains(
"(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'", "(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'",
"role.role_code='MERCHANT_OWNER'", "role.role_code='MERCHANT_OWNER'",
")) <> 24 THEN", ")) <> 25 THEN",
"客户默认管理员授权不完整"); "客户默认管理员授权不完整");
int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
@@ -36,6 +36,7 @@ class OverseasCustomerPermissionSqlContractTest {
"merchant:overseas-warehouse:discard:list", "merchant:overseas-warehouse:discard:list",
"merchant:overseas-warehouse:discard:create", "merchant:overseas-warehouse:discard:create",
"merchant:overseas-warehouse:discard:cancel", "merchant:overseas-warehouse:discard:cancel",
"merchant:overseas-warehouse:discard:confirm",
"merchant:overseas-warehouse:return-order:list", "merchant:overseas-warehouse:return-order:list",
"merchant:overseas-warehouse:return-order:create", "merchant:overseas-warehouse:return-order:create",
"merchant:overseas-warehouse:return-order:relabel"); "merchant:overseas-warehouse:return-order:relabel");
@@ -85,7 +86,7 @@ class OverseasCustomerPermissionSqlContractTest {
"海外客户权限初始化失败:客户默认管理员授权不完整", "海外客户权限初始化失败:客户默认管理员授权不完整",
"permission.app_code='MERCHANT'", "permission.app_code='MERCHANT'",
"role.role_code='MERCHANT_OWNER'", "role.role_code='MERCHANT_OWNER'",
")) <> 24 THEN"); ")) <> 25 THEN");
} }
@Test @Test
@@ -1158,6 +1158,7 @@ public class DeliveryTransferService {
required(request == null ? null : request.reason(), "取消原因不能为空"); required(request == null ? null : request.reason(), "取消原因不能为空");
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo)); OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
if (observed == null) throw notFound("Outbound order not found"); if (observed == null) throw notFound("Outbound order not found");
if ("DISCARD_OUTBOUND".equals(observed.getOutboundType())) throw conflict("请通过废弃单取消,废弃出库单随申请同步取消");
// Match dispatch picking and relabel confirmation lock order before locking outbound. // Match dispatch picking and relabel confirmation lock order before locking outbound.
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId()); : dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
@@ -1425,6 +1426,7 @@ public class DeliveryTransferService {
new LambdaQueryWrapper<OutboundOrder>().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId) new LambdaQueryWrapper<OutboundOrder>().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
.like(StringUtils.hasText(query.lxCode()), OutboundOrder::getLxCodes, query.lxCode()) .like(StringUtils.hasText(query.lxCode()), OutboundOrder::getLxCodes, query.lxCode())
.ne(StringUtils.hasText(query.lxCode()), OutboundOrder::getOutboundType, "DISCARD_OUTBOUND")
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
.eq(StringUtils.hasText(query.outboundType()), OutboundOrder::getOutboundType, query.outboundType()) .eq(StringUtils.hasText(query.outboundType()), OutboundOrder::getOutboundType, query.outboundType())
@@ -1610,7 +1612,7 @@ public class DeliveryTransferService {
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
} }
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
@@ -77,13 +77,6 @@ public class CargoDiscardController {
return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true)); return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true));
} }
@PostMapping("/{discardNo}/complete")
public ApiResponse<CargoDiscardResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
requireApprovalPermission(user);
return ApiResponse.success(service.complete(scope(user, customerId), discardNo, operator(user)));
}
private String scope(AuthenticatedUser user, String requested) { private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
@@ -46,6 +46,12 @@ public class MerchantCargoDiscardController {
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
} }
@PostMapping("/{discardNo}/confirm-completion")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CONFIRM + "')")
public ApiResponse<CargoDiscardResponses.Detail> confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) {
return ApiResponse.success(service.confirmCompletion(customer(user), discardNo, operator(user)));
}
@PostMapping("/{discardNo}/cancel") @PostMapping("/{discardNo}/cancel")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@@ -20,7 +20,14 @@ public final class CargoDiscardRequests {
@Size(max = 1000) String remark) {} @Size(max = 1000) String remark) {}
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode, public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages) {} @Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
public Item(Long orderId, Long returnCartonId, String skuCode, int discardPackages, Long inventoryPositionId) {
this(orderId, returnCartonId, skuCode, discardPackages, inventoryPositionId, null);
}
public Item(Long orderId, Long returnCartonId, String skuCode, int discardPackages) {
this(orderId, returnCartonId, skuCode, discardPackages, null, null);
}
}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
@@ -11,16 +11,37 @@ public final class CargoDiscardResponses {
String inboundCode, String inboundOrderNo, String inboundCode, String inboundOrderNo,
String overseasWarehouseId, String overseasWarehouseName, String overseasWarehouseId, String overseasWarehouseName,
LocalDateTime inboundCreatedAt, long availablePackages, LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus) {} List<CandidateSku> skus, Long inventoryPositionId, String palletNo, List<Box> cartons, boolean cartonSelectionSupported, String cartonSelectionBlockReason) {
public Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark,
String inboundCode, String inboundOrderNo, String overseasWarehouseId,
String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus, Long inventoryPositionId, String palletNo) {
this(orderId, sourceType, returnCartonId, cartonMark, inboundCode, inboundOrderNo,
overseasWarehouseId, overseasWarehouseName, inboundCreatedAt, availablePackages, skus,
inventoryPositionId, palletNo, List.of(), false, "当前库存缺少可核对的箱级记录,可按全部库存废弃");
}
public Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark,
String inboundCode, String inboundOrderNo, String overseasWarehouseId,
String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus) {
this(orderId, sourceType, returnCartonId, cartonMark, inboundCode, inboundOrderNo,
overseasWarehouseId, overseasWarehouseName, inboundCreatedAt, availablePackages, skus, null, null);
}
}
public record Box(Long cartonId, String cartonMark, String palletNo, List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {} public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String customerId, String discardNo, String inboundCodes, public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName, String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status, int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {} String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode, public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages) {} String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, List.of());
}
}
public record History(String fromStatus, String toStatus, String operatorId, public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {} String remark, LocalDateTime createdAt) {}
@@ -28,6 +28,7 @@ public class CargoDiscardApplication {
private String createdBy; private String createdBy;
private String reviewedBy; private String reviewedBy;
private String reviewRemark; private String reviewRemark;
private LocalDateTime customerConfirmedAt;
private LocalDateTime reviewedAt; private LocalDateTime reviewedAt;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -15,6 +15,7 @@ public class CargoDiscardCarton {
private Long itemId; private Long itemId;
private String customerId; private String customerId;
private Long cartonId; private Long cartonId;
private String sourceType;
private String cartonMark; private String cartonMark;
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }
@@ -16,6 +16,9 @@ public class CargoDiscardItem {
private Long orderId; private Long orderId;
private String sourceType; private String sourceType;
private Long returnCartonId; private Long returnCartonId;
private Long inventoryPositionId;
private String cartonMarksJson;
private Boolean cartonScanRequired;
private String inboundCode; private String inboundCode;
private Long profileId; private Long profileId;
private Long skuInventoryId; private Long skuInventoryId;
@@ -12,6 +12,66 @@ import java.util.List;
@Mapper @Mapper
public interface CargoDiscardInventoryMapper { public interface CargoDiscardInventoryMapper {
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("""
SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type
FROM ll_inbound_plan_carton c
JOIN ll_inventory_balance b ON b.customer_id=c.customer_id AND b.inbound_order_id=c.order_id
JOIN ll_inventory_position p ON p.customer_id=b.customer_id AND p.balance_id=b.id AND p.pallet_id=c.warehouse_pallet_id
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.status='AVAILABLE' AND c.received_at IS NOT NULL
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
UNION ALL
SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type
FROM ll_inventory_position p
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id
AND t.transfer_no=p.batch_no AND t.operating_warehouse_id=p.overseas_warehouse_id
JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND c.status='EXPECTED'
JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id
WHERE p.customer_id=#{customerId} AND p.id=#{positionId}
AND (SELECT COUNT(*) FROM ll_inventory_position current_position
WHERE current_position.customer_id=p.customer_id AND current_position.balance_id=p.balance_id
AND current_position.batch_no=p.batch_no AND current_position.overseas_warehouse_id=p.overseas_warehouse_id
AND current_position.available_packages+current_position.hold_packages+current_position.allocated_packages+current_position.picked_packages>0)=1
ORDER BY id
""")
List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> genericCartons(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Update("UPDATE ll_warehouse_transfer_carton SET status=#{toStatus},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
int changeTransferCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Update("UPDATE ll_inbound_plan_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id")
List<DropshipCarton> availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
"AND c.status='AVAILABLE' AND c.id IN <foreach collection='cartonIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND EXISTS(SELECT 1 FROM ll_dropship_carton_sku cs WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) ORDER BY c.profile_id,c.id FOR UPDATE</script>")
List<DropshipCarton> lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("cartonIds") List<Long> cartonIds);
@Select("<script>SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND p.available_packages&gt;0 AND b.available_packages&gt;0 " +
"AND NOT EXISTS(SELECT 1 FROM ll_warehouse_transfer_inventory_allocation a WHERE a.customer_id=p.customer_id AND a.inventory_position_id=p.id AND a.status='RESERVED') " +
"AND NOT EXISTS(SELECT 1 FROM ll_dropship_order_profile d WHERE d.customer_id=p.customer_id AND d.inventory_position_id=p.id) " +
"<if test='keyword != null and keyword != \"\"'>AND b.inbound_code LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY p.id</script>")
List<com.baiyue.lastleg.inventory.entity.InventoryPosition> genericPositions(@Param("customerId") String customerId, @Param("keyword") String keyword);
@Select("SELECT COUNT(*) FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId}")
long isDropshipPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," + @Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," + "o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
"COUNT(c.id) available_packages FROM ll_order o " + "COUNT(c.id) available_packages FROM ll_order o " +
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.discard.model;
import lombok.Data;
/** A real carton whose current stock position can be established from warehouse facts. */
@Data
public class CargoDiscardStockCartonRow {
private Long id;
private String cartonMark;
private String skuCode;
private String sourceType;
}
@@ -22,7 +22,7 @@ public class PdaCargoDiscardController {
} }
@PostMapping("/resolve") public ApiResponse<PdaCargoDiscardDtos.PalletTask> resolve(@AuthenticationPrincipal AuthenticatedUser user, @PostMapping("/resolve") public ApiResponse<PdaCargoDiscardDtos.PalletTask> resolve(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaCargoDiscardDtos.Scan request) { @Valid @RequestBody PdaCargoDiscardDtos.Scan request) {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo())); return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo(), request.discardNo()));
} }
@PostMapping(value = "/{discardNo}/pallets/{palletNo}/photos/files", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{discardNo}/pallets/{palletNo}/photos/files", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo> uploadPhoto( public ApiResponse<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo> uploadPhoto(
@@ -10,12 +10,18 @@ import java.util.List;
public final class PdaCargoDiscardDtos { public final class PdaCargoDiscardDtos {
private PdaCargoDiscardDtos() { } private PdaCargoDiscardDtos() { }
public record Scan(@NotBlank @Size(max = 128) String palletNo) { } public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
public Scan(String palletNo) { this(palletNo, null); }
}
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
public record Confirm(@NotEmpty List<@Valid ActualLine> lines, public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds) { } @NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
}
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status) { } Integer actualPackages, String status) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus) { } String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
} }
@@ -25,12 +25,14 @@ import java.util.stream.Collectors;
public class PdaCargoDiscardService { public class PdaCargoDiscardService {
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService;
@Resource private CargoDiscardApplicationMapper applications; @Resource private CargoDiscardApplicationMapper applications;
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService;
@Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons; @Resource private CargoDiscardCartonMapper cartons;
@Resource private DropshipOrderProfileMapper profiles; @Resource private DropshipOrderProfileMapper profiles;
@Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private CargoDiscardService discardService; @Resource private CargoDiscardService discardService;
@Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService;
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) { public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>() return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>()
@@ -41,7 +43,14 @@ public class PdaCargoDiscardService {
} }
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) { public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) {
return tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst() return resolve(identity, palletNo, null);
}
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo, String discardNo) {
var matches = tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim()) || t.cartonMarks().contains(palletNo.trim()))
.filter(t -> discardNo == null || discardNo.isBlank() || t.discardNo().equals(discardNo.trim())).toList();
if (matches.size() > 1) throw conflict("该托盘有多个废弃任务,请先选择废弃单");
return matches.stream().findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务"));
} }
@@ -62,12 +71,19 @@ public class PdaCargoDiscardService {
if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) { if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) {
throw conflict("必须提交该托盘全部 SKU 的实际废弃数量"); throw conflict("必须提交该托盘全部 SKU 的实际废弃数量");
} }
if (task.skus().stream().anyMatch(line -> !Objects.equals(actuals.get(line.itemId()), line.plannedPackages())))
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) {
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
throw conflict("必须扫描全部箱唛,或选择一键全部废弃");
}
List<CargoDiscardItem> palletItems = new ArrayList<>(); List<CargoDiscardItem> palletItems = new ArrayList<>();
for (PdaCargoDiscardDtos.Sku line : task.skus()) { for (PdaCargoDiscardDtos.Sku line : task.skus()) {
CargoDiscardItem item = items.selectById(line.itemId()); CargoDiscardItem item = items.selectById(line.itemId());
if (item == null || !application.getCustomerId().equals(item.getCustomerId()) if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId()) || !Objects.equals(application.getId(), item.getApplicationId())
|| !Objects.equals(task.profileId(), isReturnItem(item) ? item.getReturnCartonId() : item.getProfileId())) || !physicalCode(application, item).equals(task.palletNo()))
throw conflict("废弃明细不属于当前申请或托盘"); throw conflict("废弃明细不属于当前申请或托盘");
if ("COMPLETED".equals(item.getPdaStatus()) if ("COMPLETED".equals(item.getPdaStatus())
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) && !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
@@ -106,8 +122,9 @@ public class PdaCargoDiscardService {
application.setTotalPackages(actualTotal); application.setTotalPackages(actualTotal);
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal))); application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认"); CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待客户确认");
} }
outboundService.sync(application);
return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim())) return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim()))
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单")); .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
} }
@@ -142,27 +159,48 @@ public class PdaCargoDiscardService {
return application; return application;
} }
private static final String LOOSE = "散箱";
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) { private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
return items.listApplication(application.getCustomerId(), application.getId()).stream() var allItems = items.listApplication(application.getCustomerId(), application.getId());
.collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList())) var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
.entrySet().stream().map(entry -> { return grouped.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0); CargoDiscardItem first = entry.getValue().get(0);
boolean returnSource = isReturnItem(first); var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
DropshipOrderProfile profile = returnSource ? null : profiles.selectById(first.getProfileId()); i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
ReturnInventoryCarton returnCarton = returnSource ? returnCartons.selectById(first.getReturnCartonId()) : null; String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
if (!returnSource && (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))) throw conflict("废弃明细关联托盘不存在"); var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
if (returnSource && (returnCarton == null || !application.getCustomerId().equals(returnCarton.getCustomerId()))) throw conflict("废弃明细关联退货箱不存在"); boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
List<PdaCargoDiscardDtos.Sku> skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku( if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(), return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList(); entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
String status = skus.stream().allMatch(s -> "COMPLETED".equals(s.status())) ? "COMPLETED" : "PENDING"; status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks);
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), }).toList();
application.getOverseasWarehouseId(), returnSource ? firstNonBlank(returnCarton.getPalletCode(), returnCarton.getCartonMark()) : profile.getPalletNo(),
returnSource ? returnCarton.getId() : profile.getId(), status, skus);
}).toList();
} }
private String physicalGroupKey(CargoDiscardItem item) { private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) {
return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId(); String code;
if ("INVENTORY".equals(item.getSourceType())) {
var position = stockService.position(application.getCustomerId(), item.getInventoryPositionId());
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存不属于当前仓库");
code = position.getPalletNo();
} else if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) throw conflict("废弃明细关联退货箱不存在");
code = carton.getPalletCode();
} else {
var profile = profiles.selectById(item.getProfileId());
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在");
code = profile.getPalletNo();
}
return code == null || code.isBlank() ? LOOSE : code.trim();
}
private List<String> cartonMarks(CargoDiscardApplication application, CargoDiscardItem item) {
if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item);
if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId());
return carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark());
}
return cartons.listApplication(application.getCustomerId(), application.getId()).stream()
.filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList();
} }
private boolean isReturnItem(CargoDiscardItem item) { private boolean isReturnItem(CargoDiscardItem item) {
return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null;
@@ -34,6 +34,7 @@ import java.util.Map;
@Service @Service
public class CargoDiscardInventoryAdjustmentService { public class CargoDiscardInventoryAdjustmentService {
@Resource private CargoDiscardInventoryMapper inventory; @Resource private CargoDiscardInventoryMapper inventory;
@Resource private CargoDiscardStockService stockService;
@Resource private DropshipCartonLedgerMapper cartonLedgers; @Resource private DropshipCartonLedgerMapper cartonLedgers;
@Resource private DropshipCartonSkuMapper cartonSkus; @Resource private DropshipCartonSkuMapper cartonSkus;
@Resource private DropshipSkuInventoryMapper skuInventories; @Resource private DropshipSkuInventoryMapper skuInventories;
@@ -47,6 +48,11 @@ public class CargoDiscardInventoryAdjustmentService {
public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application, public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application,
CargoDiscardItem item, List<CargoDiscardCarton> links, CargoDiscardItem item, List<CargoDiscardCarton> links,
int actualPackages, String operatorId, LocalDateTime occurredAt) { int actualPackages, String operatorId, LocalDateTime occurredAt) {
if (actualPackages != item.getDiscardPackages()) throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
if ("INVENTORY".equals(item.getSourceType())) {
stockService.discard(customerId, operatorId, application, item, actualPackages);
return new AdjustmentResult(item.getDiscardPackages(), actualPackages, 0);
}
if (isReturnItem(item)) return discardReturnItem(customerId, item, actualPackages); if (isReturnItem(item)) return discardReturnItem(customerId, item, actualPackages);
if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量"); if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量");
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId()); DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
@@ -0,0 +1,124 @@
package com.baiyue.lastleg.discard.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
/** Outbound projection; inventory writes belong exclusively to the discard workflow. */
@Service
public class CargoDiscardOutboundService {
public static final String TYPE = "DISCARD_OUTBOUND";
@Resource private OutboundOrderMapper outbounds;
@Resource private OutboundPalletItemMapper pallets;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private CargoDiscardStockService stockService;
private String key(CargoDiscardApplication application) { return "CARGO_DISCARD:" + application.getId(); }
public String outboundNo(CargoDiscardApplication application) {
var outbound = outbounds.findByIdempotency(application.getCustomerId(), key(application));
return outbound == null ? null : outbound.getOutboundNo();
}
// The caller holds the application lock and owns the approval transaction.
public void create(CargoDiscardApplication application, String operatorId) {
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
var lines = items.listApplication(application.getCustomerId(), application.getId());
if (lines.isEmpty()) throw conflict("废弃单没有可出库明细");
var links = cartons.listApplication(application.getCustomerId(), application.getId());
LocalDateTime at = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
var palletCodes = new java.util.HashSet<String>();
for (var line : lines) {
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
String palletNo;
String cartonMarks;
if ("INVENTORY".equals(line.getSourceType())) {
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
palletNo = position.getPalletNo();
cartonMarks = String.join(",", stockService.cartonMarks(line));
} else if (returned) {
var carton = returnCartons.selectById(line.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
|| !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()))
throw conflict("废弃明细关联退货箱不存在或仓库不匹配");
palletNo = carton.getPalletCode() == null || carton.getPalletCode().isBlank() ? null : carton.getPalletCode();
cartonMarks = carton.getCartonMark();
} else {
var profile = profiles.selectById(line.getProfileId());
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))
throw conflict("废弃明细关联托盘不存在");
palletNo = profile.getPalletNo();
cartonMarks = links.stream().filter(c -> Objects.equals(c.getItemId(), line.getId()))
.map(c -> c.getCartonMark()).filter(Objects::nonNull).collect(java.util.stream.Collectors.joining(","));
}
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
OutboundPalletItem snapshot = new OutboundPalletItem();
// Stable one-to-one projection key in a separate table, never used to deduct stock.
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
snapshots.add(snapshot);
}
outbound.setPalletCount(palletCodes.size()); outbounds.insert(outbound);
snapshots.forEach(pallets::insert);
}
public void sync(CargoDiscardApplication application) {
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
var lines = items.listApplication(application.getCustomerId(), application.getId());
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
if ("COMPLETED".equals(line.getPdaStatus())) {
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setStatus("PICKED");
}
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
pallets.updateById(snapshot);
}
String status = switch (application.getStatus()) {
case CargoDiscardService.PDA_COMPLETED, CargoDiscardService.COMPLETED -> "OUTBOUNDED";
case CargoDiscardService.CANCELLED -> "CANCELLED";
default -> lines.stream().anyMatch(i -> "COMPLETED".equals(i.getPdaStatus())) ? "PICKING" : "WAIT_PICK";
};
outbound.setStatus(status);
outbound.setLxCodes(null);
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -61,6 +61,10 @@ public class CargoDiscardService {
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED); private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED);
@Resource private CargoDiscardPhotoService photoService; @Resource private CargoDiscardPhotoService photoService;
@Resource private CargoDiscardOutboundService outboundService;
@Resource private CargoDiscardStockService stockService;
@Resource private com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper cartonSkus;
@Resource private com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper profiles;
@Resource private CargoDiscardApplicationMapper applications; @Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper discardCartons; @Resource private CargoDiscardCartonMapper discardCartons;
@@ -72,6 +76,8 @@ public class CargoDiscardService {
@Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}") @Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
private BigDecimal feePerPackage = new BigDecimal("5.00"); private BigDecimal feePerPackage = new BigDecimal("5.00");
@@ -85,6 +91,7 @@ public class CargoDiscardService {
List<CargoDiscardResponses.Candidate> result = grouped.values().stream() List<CargoDiscardResponses.Candidate> result = grouped.values().stream()
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId()))) .map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new)); .collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
result.addAll(stockService.candidates(customerId, trim(inboundCode)));
String keyword = trim(inboundCode); String keyword = trim(inboundCode);
if (returnCartons == null || returnCartonItems == null) return result; if (returnCartons == null || returnCartonItems == null) return result;
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) { for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) {
@@ -100,7 +107,35 @@ public class CargoDiscardService {
carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null, carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
null, available, skus)); null, available, skus));
} }
return result; Map<String, String> names = result.stream().anyMatch(c -> !StringUtils.hasText(c.overseasWarehouseName()))
? warehouseNames() : Map.of();
return result.stream().map(candidate -> withBoxes(customerId, new CargoDiscardResponses.Candidate(
candidate.orderId(), candidate.sourceType(), candidate.returnCartonId(), candidate.cartonMark(),
candidate.inboundCode(), candidate.inboundOrderNo(), candidate.overseasWarehouseId(),
StringUtils.hasText(candidate.overseasWarehouseName()) ? candidate.overseasWarehouseName() : names.get(candidate.overseasWarehouseId()),
candidate.inboundCreatedAt(), candidate.availablePackages(), candidate.skus(), candidate.inventoryPositionId(),
candidate.palletNo(), candidate.cartons(), candidate.cartonSelectionSupported(), candidate.cartonSelectionBlockReason()))).toList();
}
private CargoDiscardResponses.Candidate withBoxes(String customer, CargoDiscardResponses.Candidate candidate) {
if ("INVENTORY".equals(candidate.sourceType())) return candidate;
List<CargoDiscardResponses.Box> boxes;
if ("RETURN_CARTON".equals(candidate.sourceType())) {
var carton = returnCartons.selectById(candidate.returnCartonId());
boxes = List.of(new CargoDiscardResponses.Box(candidate.returnCartonId(), candidate.cartonMark(), carton == null ? null : carton.getPalletCode(), candidate.skus()));
} else {
boxes = inventory.availableOrderCartons(customer, candidate.orderId()).stream().map(carton -> {
var profile = profiles.selectById(carton.getProfileId());
var skus = cartonSkus.listCarton(customer, carton.getId()).stream().map(sku -> new CargoDiscardResponses.CandidateSku(sku.getSkuCode(), sku.getProductName(), 1)).toList();
if (skus.isEmpty()) skus = List.of(new CargoDiscardResponses.CandidateSku(carton.getSkuCode(), null, 1));
return new CargoDiscardResponses.Box(carton.getId(), carton.getCartonMark(), profile == null ? null : profile.getPalletNo(), skus);
}).toList();
}
boolean supported = !boxes.isEmpty() && boxes.stream().allMatch(box -> box.cartonMark() != null && !box.cartonMark().isBlank());
return new CargoDiscardResponses.Candidate(candidate.orderId(), candidate.sourceType(), candidate.returnCartonId(), candidate.cartonMark(),
candidate.inboundCode(), candidate.inboundOrderNo(), candidate.overseasWarehouseId(), candidate.overseasWarehouseName(), candidate.inboundCreatedAt(),
candidate.availablePackages(), candidate.skus(), candidate.inventoryPositionId(), candidate.palletNo(), boxes, supported,
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
} }
@Transactional @Transactional
@@ -116,6 +151,7 @@ public class CargoDiscardService {
return detail(customerId, replay.getDiscardNo()); return detail(customerId, replay.getDiscardNo());
} }
Set<String> requestedDimensions = new LinkedHashSet<>(); Set<String> requestedDimensions = new LinkedHashSet<>();
Set<String> requestedBoxes = new LinkedHashSet<>();
Map<Long, LastlegOrder> orderCache = new HashMap<>(); Map<Long, LastlegOrder> orderCache = new HashMap<>();
Map<Long, ReturnInventoryCarton> returnCartonCache = new HashMap<>(); Map<Long, ReturnInventoryCarton> returnCartonCache = new HashMap<>();
String warehouseId = null; String warehouseId = null;
@@ -123,19 +159,29 @@ public class CargoDiscardService {
int total = 0; int total = 0;
for (CargoDiscardRequests.Item input : request.items()) { for (CargoDiscardRequests.Item input : request.items()) {
boolean returnSource = input.returnCartonId() != null; boolean returnSource = input.returnCartonId() != null;
if (returnSource == (input.orderId() != null)) { if (input.cartonIds() != null) {
if (input.cartonIds().isEmpty() || input.cartonIds().size() != input.discardPackages()) throw badRequest("请选择具体废弃箱子,箱数须与选择一致");
String source = input.inventoryPositionId() != null ? "INVENTORY" : "DROPSHIP";
for (Long cartonId : input.cartonIds()) if (!requestedBoxes.add(source + ":" + cartonId)) throw badRequest("同一个箱子不能重复废弃");
}
int sources = (returnSource ? 1 : 0) + (input.orderId() != null ? 1 : 0) + (input.inventoryPositionId() != null ? 1 : 0);
if (sources != 1) {
throw badRequest("Each discard item must identify exactly one inventory source"); throw badRequest("Each discard item must identify exactly one inventory source");
} }
String dimension = (returnSource ? "RETURN:" + input.returnCartonId() : "ORDER:" + input.orderId()) String dimension = (input.inventoryPositionId() != null ? "INVENTORY:" + input.inventoryPositionId() : returnSource ? "RETURN:" + input.returnCartonId() : "ORDER:" + input.orderId())
+ ":" + input.skuCode().trim(); + ":" + input.skuCode().trim();
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension); if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
String itemWarehouseId; String itemWarehouseId;
String itemWarehouseName; String itemWarehouseName;
if (returnSource) { if (input.inventoryPositionId() != null) {
var balance = stockService.validate(customerId, input);
itemWarehouseId = balance.getOverseasWarehouseId();
itemWarehouseName = balance.getOverseasWarehouseName();
} else if (returnSource) {
ReturnInventoryCarton carton = requireReturnCarton(customerId, input.returnCartonId()); ReturnInventoryCarton carton = requireReturnCarton(customerId, input.returnCartonId());
ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim()); ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim());
if (sku == null || value(sku.getQuantity()) < input.discardPackages()) { if (sku == null || value(sku.getQuantity()) != input.discardPackages()) {
throw conflict("Insufficient return inventory for SKU " + input.skuCode().trim()); throw conflict("目前仅支持全部废弃,退货 SKU 数量不匹配: " + input.skuCode().trim());
} }
returnCartonCache.put(carton.getId(), carton); returnCartonCache.put(carton.getId(), carton);
itemWarehouseId = carton.getOverseasWarehouseId(); itemWarehouseId = carton.getOverseasWarehouseId();
@@ -155,13 +201,23 @@ public class CargoDiscardService {
total = Math.addExact(total, input.discardPackages()); total = Math.addExact(total, input.discardPackages());
} }
for (Long cartonId : returnCartonCache.keySet()) {
Set<String> availableSkus = returnCartonItems.lockCarton(cartonId).stream()
.filter(sku -> value(sku.getQuantity()) > 0).map(ReturnInventoryCartonItem::getSkuCode)
.collect(java.util.stream.Collectors.toSet());
Set<String> requestedSkus = request.items().stream().filter(i -> cartonId.equals(i.returnCartonId()))
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
}
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication(); CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(IdWorker.getId()); application.setCustomerId(customerId); application.setId(IdWorker.getId()); application.setCustomerId(customerId);
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId())); application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
application.setInboundCodes(java.util.stream.Stream.concat( application.setInboundCodes(java.util.stream.Stream.concat(
orderCache.values().stream().map(LastlegOrder::getInboundCode), java.util.stream.Stream.concat(orderCache.values().stream().map(LastlegOrder::getInboundCode),
request.items().stream().filter(i -> i.inventoryPositionId() != null).map(i -> stockService.validate(customerId, i).getInboundCode())),
returnCartonCache.values().stream().map(ReturnInventoryCarton::getSourceReturnNo)) returnCartonCache.values().stream().map(ReturnInventoryCarton::getSourceReturnNo))
.distinct().sorted().collect(java.util.stream.Collectors.joining(","))); .distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage); application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
@@ -176,7 +232,9 @@ public class CargoDiscardService {
Set<Long> reservedReturnCartons = new LinkedHashSet<>(); Set<Long> reservedReturnCartons = new LinkedHashSet<>();
for (CargoDiscardRequests.Item input : request.items()) { for (CargoDiscardRequests.Item input : request.items()) {
if (input.returnCartonId() != null) { if (input.inventoryPositionId() != null) {
stockService.reserve(customerId, operatorId, application, input, now);
} else if (input.returnCartonId() != null) {
reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now, reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now,
reservedReturnCartons); reservedReturnCartons);
} else { } else {
@@ -187,6 +245,16 @@ public class CargoDiscardService {
return detail(customerId, application.getDiscardNo()); return detail(customerId, application.getDiscardNo());
} }
private List<String> selectedMarks(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
if ("INVENTORY".equals(line.getSourceType())) return stockService.cartonMarks(line);
if (isReturnItem(line)) {
var carton = returnCartons.selectById(line.getReturnCartonId());
return carton == null || carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark());
}
return discardCartons.listApplication(customer, application.getId()).stream().filter(link -> link.getItemId().equals(line.getId()))
.map(CargoDiscardCarton::getCartonMark).distinct().sorted().toList();
}
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) { public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query); validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>() LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
@@ -200,7 +268,9 @@ public class CargoDiscardService {
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo()) .le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId); .orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(this::summary).toList(); Map<String, String> names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName()))
? warehouseNames() : Map.of();
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(a -> summary(a, names)).toList();
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages()); return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
} }
@@ -209,7 +279,7 @@ public class CargoDiscardService {
if (application == null) throw notFound("Cargo discard application not found"); if (application == null) throw notFound("Cargo discard application not found");
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream() List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages())) value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value)))
.toList(); .toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream() List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -244,9 +314,10 @@ public class CargoDiscardService {
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId)); .collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
String historyRemark = request.remark(); String historyRemark = request.remark();
if (REJECTED.equals(next)) { if (REJECTED.equals(next)) {
release(customerId, links, applicationItems); release(customerId, application, links, applicationItems);
} }
if (APPROVED.equals(next)) outboundService.create(application, operatorId);
LocalDateTime reviewedAt = now(); LocalDateTime reviewedAt = now();
application.setStatus(next); application.setReviewedBy(operatorId); application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt); application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
@@ -266,12 +337,13 @@ public class CargoDiscardService {
} }
@Transactional @Transactional
public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) { public CargoDiscardResponses.Detail confirmCompletion(String customerId, String discardNo, String operatorId) {
CargoDiscardApplication application = applications.lockNo(customerId, discardNo); CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found"); if (application == null) throw notFound("Cargo discard application not found");
if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo); if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo);
if (!PDA_COMPLETED.equals(application.getStatus())) throw conflict("全部托盘 PDA 作业完成后才能确认废弃完成"); if (!PDA_COMPLETED.equals(application.getStatus())) throw conflict("全部托盘 PDA 作业完成后才能确认废弃完成");
updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "仓库确认废弃完成"); application.setCustomerConfirmedAt(now());
updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "客户确认废弃完成");
return detail(customerId, discardNo); return detail(customerId, discardNo);
} }
@@ -287,6 +359,7 @@ public class CargoDiscardService {
LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(linked.getId()); LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(linked.getId());
h.setFromStatus(linkedFrom); h.setToStatus(linkedTo); h.setEventId("CARGO_DISCARD_STATUS:" + application.getId() + ":" + linkedTo); h.setRemark(remark); h.setCreatedAt(at); orderHistories.insert(h); h.setFromStatus(linkedFrom); h.setToStatus(linkedTo); h.setEventId("CARGO_DISCARD_STATUS:" + application.getId() + ":" + linkedTo); h.setRemark(remark); h.setCreatedAt(at); orderHistories.insert(h);
} }
outboundService.sync(application);
history(customerId, application.getId(), from, to, operatorId, remark, at); history(customerId, application.getId(), from, to, operatorId, remark, at);
} }
@@ -304,7 +377,7 @@ public class CargoDiscardService {
throw conflict("客户只能取消待审核的废弃申请"); throw conflict("客户只能取消待审核的废弃申请");
} }
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId()); List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
release(customerId, links, items.listApplication(customerId, application.getId())); release(customerId, application, links, items.listApplication(customerId, application.getId()));
LocalDateTime at = now(); LocalDateTime at = now();
String from = application.getStatus(); String from = application.getStatus();
application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy()); application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy());
@@ -318,6 +391,7 @@ public class CargoDiscardService {
h.setFromStatus(from); h.setToStatus(CANCELLED); h.setEventId("CARGO_DISCARD_CANCEL:" + application.getId()); h.setRemark("废弃申请已取消"); h.setCreatedAt(at); h.setFromStatus(from); h.setToStatus(CANCELLED); h.setEventId("CARGO_DISCARD_CANCEL:" + application.getId()); h.setRemark("废弃申请已取消"); h.setCreatedAt(at);
orderHistories.insert(h); orderHistories.insert(h);
} }
if (APPROVED.equals(from)) outboundService.sync(application);
history(customerId, application.getId(), from, CANCELLED, operatorId, "废弃申请已取消", at); history(customerId, application.getId(), from, CANCELLED, operatorId, "废弃申请已取消", at);
return detail(customerId, discardNo); return detail(customerId, discardNo);
} }
@@ -325,11 +399,14 @@ public class CargoDiscardService {
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order, private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
CargoDiscardRequests.Item input, LocalDateTime now) { CargoDiscardRequests.Item input, LocalDateTime now) {
String skuCode = input.skuCode().trim(); String skuCode = input.skuCode().trim();
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) { List<DropshipCarton> selected;
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode()); if (input.cartonIds() != null) {
selected = inventory.lockSelected(customerId, order.getId(), skuCode, input.cartonIds());
} else {
if (inventory.countAvailable(customerId, order.getId(), skuCode) != input.discardPackages()) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
} }
List<DropshipCarton> selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages()); if (selected.size() != input.discardPackages()) throw conflict("箱子已被占用、SKU 不匹配或不属于当前库存,请刷新后重选");
if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request");
Map<Long, List<DropshipCarton>> byProfile = selected.stream() Map<Long, List<DropshipCarton>> byProfile = selected.stream()
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new, .collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
java.util.stream.Collectors.toList())); java.util.stream.Collectors.toList()));
@@ -343,6 +420,7 @@ public class CargoDiscardService {
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName()); item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile( item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
customerId, order.getId(), entry.getKey(), skuCode))); customerId, order.getId(), entry.getKey(), skuCode)));
item.setCartonScanRequired(input.cartonIds() != null && entry.getValue().size() < inventory.availableOrderCartons(customerId, order.getId()).stream().filter(c -> c.getProfileId().equals(entry.getKey())).count());
item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item); item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item);
for (DropshipCarton carton : entry.getValue()) { for (DropshipCarton carton : entry.getValue()) {
if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) { if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) {
@@ -356,12 +434,15 @@ public class CargoDiscardService {
} }
} }
private void release(String customerId, List<CargoDiscardCarton> links, List<CargoDiscardItem> applicationItems) { private void release(String customerId, CargoDiscardApplication application, List<CargoDiscardCarton> links, List<CargoDiscardItem> applicationItems) {
Set<Long> genericItems = applicationItems.stream().filter(i -> "INVENTORY".equals(i.getSourceType())).map(CargoDiscardItem::getId).collect(java.util.stream.Collectors.toSet());
for (CargoDiscardCarton link : links) { for (CargoDiscardCarton link : links) {
if (genericItems.contains(link.getItemId())) continue;
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) { if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
throw conflict("Reserved cargo status changed before rejection"); throw conflict("Reserved cargo status changed before rejection");
} }
} }
applicationItems.stream().filter(i -> "INVENTORY".equals(i.getSourceType())).forEach(i -> stockService.release(customerId, application, i));
applicationItems.stream().filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId) applicationItems.stream().filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId)
.filter(java.util.Objects::nonNull).distinct().forEach(cartonId -> { .filter(java.util.Objects::nonNull).distinct().forEach(cartonId -> {
if (returnCartons.releaseDiscard(cartonId, customerId) != 1) { if (returnCartons.releaseDiscard(cartonId, customerId) != 1) {
@@ -373,7 +454,7 @@ public class CargoDiscardService {
private void reserveReturn(String customerId, CargoDiscardApplication application, ReturnInventoryCarton carton, private void reserveReturn(String customerId, CargoDiscardApplication application, ReturnInventoryCarton carton,
CargoDiscardRequests.Item input, LocalDateTime now, Set<Long> reservedCartons) { CargoDiscardRequests.Item input, LocalDateTime now, Set<Long> reservedCartons) {
ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim()); ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim());
if (sku == null || value(sku.getQuantity()) < input.discardPackages()) { if (sku == null || value(sku.getQuantity()) != input.discardPackages()) {
throw conflict("Available return inventory changed; retry the discard request"); throw conflict("Available return inventory changed; retry the discard request");
} }
if (reservedCartons.add(carton.getId()) && returnCartons.markDiscardPending(carton.getId(), customerId) != 1) { if (reservedCartons.add(carton.getId()) && returnCartons.markDiscardPending(carton.getId(), customerId) != 1) {
@@ -410,10 +491,26 @@ public class CargoDiscardService {
} }
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) { private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
return summary(value, StringUtils.hasText(value.getOverseasWarehouseName()) ? Map.of() : warehouseNames());
}
private Map<String, String> warehouseNames() {
var response = warehouseDirectory.receivingWarehouses(internalToken);
if (response == null || !response.successful() || response.data() == null)
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "海外仓库目录暂不可用");
Map<String, String> names = new HashMap<>();
for (var warehouse : response.data()) {
if (warehouse.warehouseId() != null && StringUtils.hasText(warehouse.warehouseName()))
names.put(warehouse.warehouseId().toString(), warehouse.warehouseName());
}
return names;
}
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(), value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt()); value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
} }
private void history(String customerId, Long applicationId, String from, String to, String operatorId, private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -0,0 +1,142 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.*;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.time.LocalDateTime;
/** Stock eligibility is based on physical available positions, independent of the source document type. */
@Service
public class CargoDiscardStockService {
@Resource private CargoDiscardInventoryMapper stock;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryCommandService commands;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper links;
@Resource private ObjectMapper objectMapper;
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
return stock.genericPositions(customer, keyword).stream().map(position -> {
var balance = balances.selectById(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
var cartons = stock.genericCartons(customer, position.getId());
boolean complete = cartons.size() == available && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank())
&& cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size();
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
List.of(new CargoDiscardResponses.CandidateSku(c.getSkuCode(), balance.getProductNameZh(), 1)))).toList() : List.<CargoDiscardResponses.Box>of();
return new CargoDiscardResponses.Candidate(null, "INVENTORY", null, null,
balance.getInboundCode(), balance.getInboundOrderNo(), balance.getOverseasWarehouseId(),
balance.getOverseasWarehouseName(), position.getCreatedAt(), available,
List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)),
position.getId(), position.getPalletNo(), boxes, complete,
complete ? null : "当前库存缺少完整箱唛与托盘对应记录,可按全部库存废弃");
}).filter(c -> c.availablePackages() > 0).toList();
}
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
var position = position(customer, request.inventoryPositionId());
var balance = balances.selectById(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
throw conflict("库存不属于当前客户或仓库");
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
if (request.cartonIds() != null) {
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
throw conflict("废弃箱子数量与所选箱子不一致或重复");
var cartons = stock.genericCartons(customer, position.getId());
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
} else if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
if (stock.isDropshipPosition(customer, position.getId()) > 0) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
return balance;
}
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
var balance = validate(customer, request);
var position = position(customer, request.inventoryPositionId());
var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId());
line.setCustomerId(customer); line.setSourceType("INVENTORY"); line.setInventoryPositionId(position.getId());
line.setInboundCode(balance.getInboundCode()); line.setSkuCode(sku(balance)); line.setProductName(balance.getProductNameZh());
line.setAvailablePackagesSnapshot(Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())))); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at);
var availableCartons = stock.genericCartons(customer, position.getId());
var selected = request.cartonIds() == null
? (availableCartons.size() == request.discardPackages() ? availableCartons : List.<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>of())
: availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList();
if (!selected.isEmpty()) {
for (var carton : selected) {
if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "EXPECTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选");
var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId());
link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link);
}
snapshotMarks(line, selected.stream().map(c -> c.getCartonMark()).toList());
line.setCartonScanRequired(request.discardPackages() < value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages()));
} else if (position.getPalletNo() == null || position.getPalletNo().isBlank()) {
var marks = stock.looseCartonMarks(customer, position.getId());
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
snapshotMarks(line, marks);
}
commands.hold(customer, operator, move(application, line, request.discardPackages(), "HOLD"));
items.insert(line);
}
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
changeSelectedCartons(customer, application, line, "AVAILABLE");
commands.release(customer, application.getCreatedBy(), move(application, line, line.getDiscardPackages(), "RELEASE"));
}
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
if (actual != line.getDiscardPackages()) throw conflict("必须完成本次申请所选全部箱子,不能漏出");
changeSelectedCartons(customer, application, line, "DISCARDED");
commands.discardHeld(customer, operator, move(application, line, actual, "DISCARD"));
}
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
if ("TRANSFER".equals(source)) return stock.changeTransferCartonStatus(customer, cartonId, from, to);
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
return stock.changeGenericCartonStatus(customer, cartonId, from, to);
}
private void snapshotMarks(CargoDiscardItem line, List<String> marks) {
try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); }
catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); }
}
private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) {
for (var link : links.listApplication(customer, application.getId())) {
if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "EXPECTED" : status) != 1)
throw conflict("预留箱子的库存状态发生变化");
}
}
private InventoryCommandRequests.Move move(CargoDiscardApplication application, CargoDiscardItem line, int count, String action) {
String cycle = "CARGO_DISCARD:" + application.getId() + ":" + line.getId();
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), count, application.getDiscardNo(),
null, cycle, cycle + ":" + action, "货物废弃 " + action);
}
public InventoryPosition position(String customer, Long id) {
var position = positions.findTenantPosition(customer, id);
if (position == null) throw conflict("废弃库存仓位不存在或不属于当前客户");
return position;
}
public List<String> cartonMarks(CargoDiscardItem line) {
if (line.getCartonMarksJson() == null) return List.of();
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
}
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
private static long value(Long value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -244,7 +244,7 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " + "LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " + "LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " + "AND wtp.warehouse_pallet_id=pos.pallet_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + "WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" + "<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" + "<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" + "<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
@@ -271,7 +271,7 @@ public interface DispatchCandidateMapper {
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " + "FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " + "JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
"AND c.relabel_application_id=a.id AND c.overseas_warehouse_id=a.overseas_warehouse_id " + "AND c.relabel_application_id=a.id AND c.overseas_warehouse_id=a.overseas_warehouse_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + "WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL " + "AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" + "<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" + "<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
@@ -116,8 +116,8 @@ public class DispatchService {
if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
} }
if (!"TRUCK".equals(application.getDeliveryMethod())) { if ("EXPRESS".equals(application.getDeliveryMethod())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单不是卡车派送,无需进行排车"); throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单为快递派送,无需进行排车");
} }
return application.getCustomerId(); return application.getCustomerId();
} }
@@ -598,7 +598,7 @@ public class DispatchService {
} }
relabelBindingGuard.requireBound(application); relabelBindingGuard.requireBound(application);
relabelMeasurements.requireMeasured(application); relabelMeasurements.requireMeasured(application);
if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) if ("EXPRESS".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态"); throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat( List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(), measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
@@ -294,6 +294,40 @@ public class InventoryCommandService {
} }
} }
/** Internal discard workflow: remove only stock held by the same discard item. */
@Transactional
public InventoryCommandResponse discardHeld(String customerId, String operatorId, InventoryCommandRequests.Move request) {
if (request.packages() <= 0) throw badRequest("废弃数量必须为正数");
return execute(customerId, operatorId, "DISCARD", request.idempotencyKey(), request, operation -> {
Locked locked = locked(customerId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireReservation(customerId, request.positionId(), null, request.packages(), "DISCARD");
long owned = ledgers.orderHoldCycle(customerId, balance.getInboundOrderId(),
required(request.sourceEventId(), "废弃库存预留标识不能为空")).stream()
.filter(l -> Objects.equals(l.getPositionId(), position.getId())
&& Objects.equals(l.getBusinessOrderNo(), request.businessOrderNo()))
.mapToLong(l -> value(l.getDeltaPackages())).sum();
requireAtLeast(owned, request.packages(), "discard reservation");
long before = value(position.getHoldPackages());
requireAtLeast(before, request.packages(), "held");
requireAtLeast(value(balance.getHoldPackages()), request.packages(), "balance held");
position.setHoldPackages(before - request.packages());
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
balance.setHoldPackages(value(balance.getHoldPackages()) - request.packages()); touch(balance);
balances.updateById(balance);
boolean empty = position.getPalletId() != null && commands.palletPackages(position.getPalletId()) == 0;
if (empty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "DISCARD",
"HOLD", "DISCARDED", before, -request.packages(), before - request.packages(),
request.businessOrderNo(), balance.getInboundOrderNo(), request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), request.remark());
return response(operation, balance, position, empty);
});
}
private InventoryCommandResponse ship(String customerId, String operatorId, private InventoryCommandResponse ship(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) { InventoryCommandRequests.Move request, String skuSnapshot) {
return ship(customerId, operatorId, request, skuSnapshot, null); return ship(customerId, operatorId, request, skuSnapshot, null);
@@ -80,6 +80,7 @@ public class PdaRelabelService {
return result; return result;
} }
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard;
@Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements;
public static final String AVAILABLE = "AVAILABLE"; public static final String AVAILABLE = "AVAILABLE";
public static final String PROCESSING = "PROCESSING"; public static final String PROCESSING = "PROCESSING";
public static final String WAIT_REVIEW = "WAIT_REVIEW"; public static final String WAIT_REVIEW = "WAIT_REVIEW";
@@ -454,6 +455,7 @@ public class PdaRelabelService {
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion()); requireVersion(task, request.expectedVersion());
if (!request.manualCompletion()) bindingGuard.requireBound(application); if (!request.manualCompletion()) bindingGuard.requireBound(application);
palletMeasurements.requireMeasured(application);
List<RelabelItem> detailItems = itemList(application); List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task); List<PdaRelabelUnitFact> facts = factList(task);
boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount())); boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount()));
@@ -814,7 +816,7 @@ public class PdaRelabelService {
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(), task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(), task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(), task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), "TRUCK".equals(application.getDeliveryMethod())); itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), !"EXPRESS".equals(application.getDeliveryMethod()));
} }
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) { private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
@@ -78,7 +78,7 @@ public class RelabelApplicationService implements WalletResultHandler {
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND"; public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING, private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP"); private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP");
@Resource private RelabelApplicationMapper applications; @Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items; @Resource private RelabelItemMapper items;
@@ -373,7 +373,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) { if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
throw badRequest("SKU-only relabel does not create an outbound fulfillment"); throw badRequest("SKU-only relabel does not create an outbound fulfillment");
} }
if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) { if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS").contains(deliveryMethod)) {
throw badRequest("Shipping-label printing is only available for express delivery"); throw badRequest("Shipping-label printing is only available for express delivery");
} }
String requestHash = hash(request); String requestHash = hash(request);
@@ -951,9 +951,9 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setCustomerConfirmationKey(key); application.setCustomerConfirmationKey(key);
application.setCustomerConfirmedAt(now); application.setCustomerConfirmedAt(now);
application.setFulfillmentStatus(requiresOutbound application.setFulfillmentStatus(requiresOutbound
? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION") ? ("EXPRESS".equals(application.getDeliveryMethod()) ? "WAIT_OUTBOUND_CREATION" : "WAIT_PRE_DISPATCH")
: "NOT_REQUIRED"); : "NOT_REQUIRED");
if (requiresOutbound && !existingStockTransfer && !"TRUCK".equals(application.getDeliveryMethod())) { if (requiresOutbound && !existingStockTransfer && "EXPRESS".equals(application.getDeliveryMethod())) {
createDirectOutbound(application, operatorId, now); createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED"); application.setFulfillmentStatus("OUTBOUND_CREATED");
} }
@@ -993,7 +993,7 @@ public class RelabelApplicationService implements WalletResultHandler {
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now); carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
// 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。 // 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。
// 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。 // 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。
boolean awaitingTruck = requiresOutbound && "TRUCK".equals(application.getDeliveryMethod()); boolean awaitingTruck = requiresOutbound && !"EXPRESS".equals(application.getDeliveryMethod());
if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) { if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) {
carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelApplicationId(null); carton.setRelabelNo(null);
} }
@@ -58,7 +58,7 @@ public class RelabelBindingGuard {
returnLines.lockCarton(item.getReturnCartonId()).forEach(line -> actual.merge(line.getSkuCode().toUpperCase(Locale.ROOT), line.getQuantity(), Long::sum)); returnLines.lockCarton(item.getReturnCartonId()).forEach(line -> actual.merge(line.getSkuCode().toUpperCase(Locale.ROOT), line.getQuantity(), Long::sum));
if (!expected.equals(actual)) throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱"); if (!expected.equals(actual)) throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱");
} }
if (!"TRUCK".equals(app.getDeliveryMethod())) continue; if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())) if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()))
throw conflict("退货箱尚未完成排车托盘绑定"); throw conflict("退货箱尚未完成排车托盘绑定");
continue; continue;
@@ -76,7 +76,7 @@ public class RelabelBindingGuard {
var actual = new HashMap<String, Long>(); var actual = new HashMap<String, Long>();
stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum)); stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum));
if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工"); if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工");
if (!"TRUCK".equals(app.getDeliveryMethod())) continue; if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
if (carton == null || carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) if (carton == null || carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|| !"PROCESSING".equals(carton.getRelabelStatus())) || !"PROCESSING".equals(carton.getRelabelStatus()))
@@ -10,6 +10,13 @@ import java.util.List;
/** Immutable movements retain original return ownership when cartons are merged again. */ /** Immutable movements retain original return ownership when cartons are merged again. */
@Mapper @Mapper
public interface ReturnPackingTraceMapper { public interface ReturnPackingTraceMapper {
// Collapse fragments within one operation; separate physical operations remain separate history.
String HISTORY_SELECT = "SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,"
+ "t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,SUM(t.quantity) AS quantity,"
+ "MIN(t.created_at) AS created_at FROM ll_return_packing_trace t "
+ "JOIN ll_return_inventory_operation o ON o.id=t.operation_id ";
String HISTORY_GROUP = " GROUP BY t.operation_id,JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')),"
+ "t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code ";
record Trace(long id, String sourceCartonMark, String targetCartonMark, String returnNo, record Trace(long id, String sourceCartonMark, String targetCartonMark, String returnNo,
String skuCode, long quantity, LocalDateTime createdAt) { } String skuCode, long quantity, LocalDateTime createdAt) { }
record Ownership(String returnNo, long quantity) { } record Ownership(String returnNo, long quantity) { }
@@ -28,15 +35,18 @@ public interface ReturnPackingTraceMapper {
@Select("SELECT id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND return_no=#{returnNo} ORDER BY created_at,id") @Select("SELECT id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND return_no=#{returnNo} ORDER BY created_at,id")
List<Trace> listReturn(@Param("customerId") String customerId, @Param("returnNo") String returnNo); List<Trace> listReturn(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND t.return_no=#{returnNo} AND t.source_carton_mark<>'' ORDER BY t.created_at,t.id") @Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND t.return_no=#{returnNo} AND t.source_carton_mark<>''"
+ HISTORY_GROUP + "ORDER BY MIN(t.created_at),MIN(t.id)")
List<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.PackingDetail> returnHistory(@Param("customerId") String customerId, @Param("returnNo") String returnNo); List<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.PackingDetail> returnHistory(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND t.source_carton_mark<>'' ORDER BY t.created_at,t.id") @Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND t.source_carton_mark<>''"
+ HISTORY_GROUP + "ORDER BY MIN(t.created_at),MIN(t.id)")
List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> planHistory(@Param("customerId") String customerId, @Param("planNo") String planNo); List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> planHistory(@Param("customerId") String customerId, @Param("planNo") String planNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.operatorName')) FROM ll_return_inventory_operation o WHERE JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND o.operation_type='MERGE' ORDER BY o.id LIMIT 1") @Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.operatorName')) FROM ll_return_inventory_operation o WHERE JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND o.operation_type='MERGE' ORDER BY o.id LIMIT 1")
String planOperator(@Param("planNo") String planNo); String planOperator(@Param("planNo") String planNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark}) ORDER BY t.created_at DESC,t.id DESC") @Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark})"
+ HISTORY_GROUP + "ORDER BY MIN(t.created_at) DESC,MIN(t.id) DESC")
List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> listCartonHistory(@Param("customerId") String customerId, @Param("mark") String mark); List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> listCartonHistory(@Param("customerId") String customerId, @Param("mark") String mark);
} }
@@ -1190,7 +1190,13 @@ public class ReturnInventoryService {
if (origins.length != 1) throw conflict("历史混箱缺少逐 SKU 来源明细,不能继续合并"); if (origins.length != 1) throw conflict("历史混箱缺少逐 SKU 来源明细,不能继续合并");
result.add(new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(origins[0], quantity - traced)); result.add(new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(origins[0], quantity - traced));
} }
return result; // Existing trace and the original untracked remainder can belong to the same return.
// Coalesce before checking for multiple owners or recording a new movement.
Map<String, Long> byReturn = new LinkedHashMap<>();
for (var origin : result) byReturn.merge(origin.returnNo(), origin.quantity(), Math::addExact);
return byReturn.entrySet().stream()
.map(entry -> new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(entry.getKey(), entry.getValue()))
.toList();
} }
private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) { private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) {
@@ -58,6 +58,7 @@ public final class OverseasPermissions {
public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm"; public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm";
public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list"; public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list";
public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create"; public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create";
public static final String MERCHANT_DISCARD_CONFIRM = "merchant:overseas-warehouse:discard:confirm";
public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel"; public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel";
public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list"; public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list";
public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create"; public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create";
@@ -77,6 +77,21 @@ public class PdaWarehouseTransferController {
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); .contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
} }
@GetMapping("/{transferNo}/carton-label")
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaCartonLabel(identity.overseasWarehouseId(), transferNo));
}
@PostMapping("/{transferNo}/cartons/count")
public ApiResponse<WarehouseTransferResponses.Detail> countCartons(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.CountCartons request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.countCartons(identity.operatorKey(), identity.overseasWarehouseId(), transferNo, request));
}
@PostMapping("/{transferNo}/palletize") @PostMapping("/{transferNo}/palletize")
public ApiResponse<WarehouseTransferResponses.Detail> palletize( public ApiResponse<WarehouseTransferResponses.Detail> palletize(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@@ -97,6 +97,24 @@ public final class WarehouseTransferRequests {
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey, public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {} @Size(max = 1000) String remark) {}
public record CountCartons(
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 5000) List<@Valid @NotNull CountCarton> boxes,
@Size(max = 1000) String discrepancyReason
) {}
public record CountCarton(
@Size(max = 255) String cartonMark,
Long transferItemId,
@NotNull Long transferPalletId,
@NotNull @DecimalMin("0.01") BigDecimal length,
@NotNull @DecimalMin("0.01") BigDecimal width,
@NotNull @DecimalMin("0.01") BigDecimal height,
@NotNull @DecimalMin("0.01") BigDecimal weight,
@NotBlank @Size(max = 255) String skuCode,
@Positive @Max(2147483647) long quantity
) {}
public record Palletize( public record Palletize(
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds @NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
@@ -104,13 +122,7 @@ public final class WarehouseTransferRequests {
public record PalletDraft( public record PalletDraft(
Long palletId, Long palletId,
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey
@Positive int cartonCount,
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg
) {} ) {}
public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {} public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {}
@@ -151,8 +163,13 @@ public final class WarehouseTransferRequests {
@Size(max = 128) String skuCode, @Size(max = 128) String skuCode,
@Size(max = 255) String productNameZh, @Size(max = 255) String productNameZh,
@Positive int packageCount, @Positive int packageCount,
@Size(max = 255) String shippingMark @Size(max = 255) String shippingMark,
) {} @Positive Long quantity
) {
public Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {
this(skuCode, productNameZh, packageCount, shippingMark, null);
}
}
public record RelabelItem( public record RelabelItem(
@NotBlank @Size(max = 32) String operationType, @NotBlank @Size(max = 32) String operationType,
@@ -6,8 +6,10 @@ import java.util.List;
public final class WarehouseTransferResponses { public final class WarehouseTransferResponses {
private WarehouseTransferResponses() {} private WarehouseTransferResponses() {}
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {} public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {}
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {} public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status,
Long transferItemId, Long transferPalletId, BigDecimal length, BigDecimal width, BigDecimal height,
BigDecimal weight, Long quantity, String discrepancyReason, String countedBy, LocalDateTime countedAt) {}
public record Label(String transferNo, List<Carton> cartons, String printableHtml) {} public record Label(String transferNo, List<Carton> cartons, String printableHtml) {}
public record DestinationResult(String customerId, String transferNo) {} public record DestinationResult(String customerId, String transferNo) {}
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {} public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
@@ -13,12 +13,23 @@ public class WarehouseTransferCarton {
@TableId(type = IdType.INPUT) private Long id; @TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId; private Long transferOrderId;
private Long transferItemId; private Long transferItemId;
private Long transferPalletId;
private String customerId; private String customerId;
private String cartonMark; private String cartonMark;
private String customerCartonMark; private String customerCartonMark;
private Integer cartonIndex; private Integer cartonIndex;
private Integer totalCartons; private Integer totalCartons;
private String status; private String status;
private java.math.BigDecimal lengthCm;
private java.math.BigDecimal widthCm;
private java.math.BigDecimal heightCm;
private java.math.BigDecimal grossWeightKg;
private Long countedQuantity;
private String countIdempotencyKey;
private String countRequestHash;
private String discrepancyReason;
private String countedBy;
private LocalDateTime countedAt;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }
@@ -15,6 +15,7 @@ public class WarehouseTransferItem {
private String skuCode; private String skuCode;
private String productNameZh; private String productNameZh;
private Integer packageCount; private Integer packageCount;
private Long declaredQuantity;
private String shippingMark; private String shippingMark;
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }
@@ -525,7 +525,7 @@ public class WarehouseTransferService {
new LambdaQueryWrapper<WarehouseTransferItem>().eq(WarehouseTransferItem::getCustomerId, customerId) new LambdaQueryWrapper<WarehouseTransferItem>().eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)) .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId))
.stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(), .stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(),
value.getPackageCount(), value.getShippingMark())).toList(); value.getPackageCount(), value.getShippingMark(), value.getDeclaredQuantity())).toList();
Map<Long, WarehouseTransferResponses.Item> itemById = items.stream() Map<Long, WarehouseTransferResponses.Item> itemById = items.stream()
.collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity())); .collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity()));
List<WarehouseTransferResponses.SourceOrder> sources = sourceOrders.selectList( List<WarehouseTransferResponses.SourceOrder> sources = sourceOrders.selectList(
@@ -535,7 +535,7 @@ public class WarehouseTransferService {
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList(); value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId()) List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId())
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), .stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(),
value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList();
List<WarehouseTransferPalletItem> palletItemRows = transferPalletItems == null List<WarehouseTransferPalletItem> palletItemRows = transferPalletItems == null
? List.of() : transferPalletItems.listOrder(customerId, transfer.getId()); ? List.of() : transferPalletItems.listOrder(customerId, transfer.getId());
Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream() Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream()
@@ -607,13 +607,21 @@ public class WarehouseTransferService {
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
} }
@Transactional(readOnly = true)
public WarehouseTransferResponses.Label pdaCartonLabel(long warehouseId, String transferNo) {
var transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId));
if (transfer == null) throw notFound("当前仓没有该转运单");
return cartonLabel(transfer.getCustomerId(), transferNo);
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) { public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) {
WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found"); if (transfer == null) throw notFound("Warehouse transfer order not found");
if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels"); if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels");
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId()).stream() List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId()).stream()
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); .filter(value -> !"NOT_RECEIVED".equals(value.getStatus()))
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList();
StringBuilder labels = new StringBuilder(); StringBuilder labels = new StringBuilder();
for (WarehouseTransferResponses.Carton carton : cartons) { for (WarehouseTransferResponses.Carton carton : cartons) {
labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>") labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>")
@@ -689,9 +697,10 @@ public class WarehouseTransferService {
WarehouseTransferRequests.PalletDraft request) { WarehouseTransferRequests.PalletDraft request) {
WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo);
String customerId = transfer.getCustomerId(); String customerId = transfer.getCustomerId();
validatePalletMeasurement(request.lengthCm(), request.widthCm(), request.heightCm(), request.volumeCbm());
String requestHash = hash(request); String requestHash = hash(request);
List<WarehouseTransferPallet> existing = transferPallets.listOrder(customerId, transfer.getId()); List<WarehouseTransferPallet> existing = transferPallets.listOrder(customerId, transfer.getId());
if (transferCartons.listOrder(customerId, transfer.getId()).stream().anyMatch(box -> box.getCountedAt() != null))
throw conflict("箱子清点已确认,不能再新增或修改托盘");
WarehouseTransferPallet pallet; WarehouseTransferPallet pallet;
if (request.palletId() == null) { if (request.palletId() == null) {
pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey())) pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey()))
@@ -710,9 +719,9 @@ public class WarehouseTransferService {
if (pallet == null) throw notFound("Warehouse transfer pallet draft not found"); if (pallet == null) throw notFound("Warehouse transfer pallet draft not found");
if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked"); if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked");
} }
pallet.setCartonCount(request.cartonCount()); pallet.setLengthCm(request.lengthCm()); pallet.setCartonCount(0); pallet.setLengthCm(BigDecimal.ZERO);
pallet.setWidthCm(request.widthCm()); pallet.setHeightCm(request.heightCm()); pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO);
pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg()); pallet.setVolumeCbm(BigDecimal.ZERO); pallet.setGrossWeightKg(BigDecimal.ZERO);
pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash); pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash);
pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet);
fleetPool.receiving(customerId, transferNo, pdaOperatorId()); fleetPool.receiving(customerId, transferNo, pdaOperatorId());
@@ -782,6 +791,94 @@ public class WarehouseTransferService {
return detail(transfer.getCustomerId(), transferNo); return detail(transfer.getCustomerId(), transferNo);
} }
@Transactional
public WarehouseTransferResponses.Detail countCartons(String operatorId, long warehouseId, String transferNo,
WarehouseTransferRequests.CountCartons request) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
if (transfer == null) throw notFound("当前仓没有该转运单");
String customerId = transfer.getCustomerId();
List<WarehouseTransferCarton> cartons = transferCartons.listOrder(customerId, transfer.getId());
String requestHash = hash(request);
boolean sameKey = cartons.stream().anyMatch(box -> request.idempotencyKey().equals(box.getCountIdempotencyKey()));
if (sameKey) {
if (cartons.stream().anyMatch(box -> !request.idempotencyKey().equals(box.getCountIdempotencyKey())
|| !requestHash.equals(box.getCountRequestHash()))) throw conflict("幂等键已用于其他清点内容");
return detail(customerId, transferNo);
}
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("转运单已结束入库测量");
if (cartons.stream().anyMatch(box -> box.getCountedAt() != null)) throw conflict("本单已完成清点,请刷新查看结果");
if (cartons.isEmpty()) throw conflict("转运单缺少箱号预报");
if (request.boxes().size() != transfer.getPackageCount() && !StringUtils.hasText(request.discrepancyReason()))
throw badRequest("实收箱数与预报不一致时必须填写差异原因");
Map<String, WarehouseTransferCarton> byMark = cartons.stream().collect(Collectors.toMap(WarehouseTransferCarton::getCartonMark, Function.identity()));
Map<Long, WarehouseTransferItem> byItem = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId).eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()))
.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity()));
List<WarehouseTransferPallet> countedPallets = transferPallets.listOrder(customerId, transfer.getId());
if (countedPallets.stream().anyMatch(pallet -> request.boxes().stream().noneMatch(box -> pallet.getId().equals(box.transferPalletId()))))
throw badRequest("每个托盘必须至少分配一个实收箱,请核对箱子所属托盘");
Map<Long, WarehouseTransferPallet> byPallet = countedPallets.stream().collect(Collectors.toMap(WarehouseTransferPallet::getId, Function.identity()));
Set<String> seen = new java.util.HashSet<>();
for (var input : request.boxes()) {
var targetPallet = byPallet.get(input.transferPalletId());
if (targetPallet == null || targetPallet.getWarehousePalletId() == null
|| !Set.of("GENERATED", "BOUND").contains(targetPallet.getStatus()))
throw badRequest("请先创建本单托盘号,再选择箱子所属托盘");
var carton = byMark.get(input.cartonMark());
if (StringUtils.hasText(input.cartonMark()) && (carton == null || !seen.add(input.cartonMark())))
throw badRequest("箱号不属于本单或重复填写");
if (carton == null && input.transferItemId() == null) throw badRequest("新增实收箱必须选择本单申报明细");
var item = byItem.get(carton == null ? input.transferItemId() : carton.getTransferItemId());
if (item == null || !item.getSkuCode().equals(input.skuCode())) throw badRequest("箱内 SKU 必须与该箱申报明细一致");
if (input.quantity() <= 0 || input.quantity() > Integer.MAX_VALUE || input.length() == null || input.width() == null
|| input.height() == null || input.weight() == null || input.length().signum() <= 0 || input.width().signum() <= 0
|| input.height().signum() <= 0 || input.weight().signum() <= 0) throw badRequest("每箱必须填写有效尺寸、重量和实点件数");
}
LocalDateTime now = LocalDateTime.now();
for (var carton : cartons) {
if (seen.contains(carton.getCartonMark())) continue;
carton.setStatus("NOT_RECEIVED"); carton.setCountedAt(now); carton.setCountedBy(operatorId);
carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash);
carton.setDiscrepancyReason(trim(request.discrepancyReason())); carton.setUpdatedAt(now); transferCartons.updateById(carton);
}
int nextIndex = cartons.stream().mapToInt(WarehouseTransferCarton::getCartonIndex).max().orElse(0);
for (var input : request.boxes()) {
var carton = byMark.get(input.cartonMark());
boolean added = carton == null;
if (added) {
carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId());
carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId);
carton.setCartonIndex(++nextIndex); carton.setTotalCartons(transfer.getPackageCount());
carton.setCartonMark(transferNo + "#EX-" + nextIndex); carton.setCreatedAt(now);
}
carton.setTransferPalletId(input.transferPalletId());
carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height());
carton.setGrossWeightKg(input.weight()); carton.setCountedQuantity(input.quantity());
carton.setCountedBy(operatorId); carton.setCountedAt(now); carton.setStatus("COUNTED");
carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash); carton.setUpdatedAt(now);
carton.setDiscrepancyReason(trim(request.discrepancyReason()));
if (added) transferCartons.insert(carton); else transferCartons.updateById(carton);
}
// Aggregate only the submitted, physically assigned boxes; never infer SKU placement from declaration order.
for (var pallet : countedPallets) {
var assigned = request.boxes().stream().filter(box -> pallet.getId().equals(box.transferPalletId())).toList();
pallet.setCartonCount(assigned.size());
pallet.setVolumeCbm(assigned.stream().map(box -> box.length().multiply(box.width()).multiply(box.height())
.movePointLeft(6)).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, java.math.RoundingMode.HALF_UP));
pallet.setGrossWeightKg(assigned.stream().map(WarehouseTransferRequests.CountCarton::weight).reduce(BigDecimal.ZERO, BigDecimal::add));
pallet.setUpdatedAt(now); transferPallets.updateById(pallet);
}
return detail(customerId, transferNo);
}
private List<WarehouseTransferCarton> requireCountedCartons(WarehouseTransferOrder transfer) {
var cartons = transferCartons.listOrder(transfer.getCustomerId(), transfer.getId());
if (cartons.isEmpty() || cartons.stream().anyMatch(box -> box.getCountedAt() == null)
|| cartons.stream().noneMatch(box -> "COUNTED".equals(box.getStatus())))
throw conflict("请先完成全部箱子的尺寸测量和箱内清点");
return cartons.stream().filter(box -> "COUNTED".equals(box.getStatus())).toList();
}
private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) { private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
if (transfer == null) throw notFound("Warehouse transfer order not found"); if (transfer == null) throw notFound("Warehouse transfer order not found");
@@ -818,6 +915,7 @@ public class WarehouseTransferService {
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
throw conflict("Warehouse transfer is not awaiting PDA receiving"); throw conflict("Warehouse transfer is not awaiting PDA receiving");
} }
List<WarehouseTransferCarton> countedCartons = requireCountedCartons(transfer);
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>() List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId) .eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId));
@@ -838,8 +936,10 @@ public class WarehouseTransferService {
if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) { if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) {
throw conflict("Every pallet must be generated and scan-bound before completing palletization"); throw conflict("Every pallet must be generated and scan-bound before completing palletization");
} }
if (persistedPallets.stream().anyMatch(pallet -> pallet.getCartonCount() == null || pallet.getCartonCount() <= 0)) throw conflict("每个托盘必须至少分配一个实收箱");
int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum(); int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum();
List<DerivedPalletAllocation> derivedAllocations = derivePersistedPalletAllocations(items, persistedPallets); if (palletCartons != countedCartons.size()) throw conflict("打托箱数必须与已清点实收箱数一致");
List<DerivedPalletAllocation> derivedAllocations = deriveCountedPalletAllocations(countedCartons, persistedPallets);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
List<WarehouseTransferPalletItem> completedPalletItems = new ArrayList<>(); List<WarehouseTransferPalletItem> completedPalletItems = new ArrayList<>();
for (int index = 0; index < persistedPallets.size(); index++) { for (int index = 0; index < persistedPallets.size(); index++) {
@@ -1452,29 +1552,20 @@ public class WarehouseTransferService {
return result; return result;
} }
static List<DerivedPalletAllocation> derivePersistedPalletAllocations(List<WarehouseTransferItem> items, static List<DerivedPalletAllocation> deriveCountedPalletAllocations(List<WarehouseTransferCarton> cartons,
List<WarehouseTransferPallet> pallets) { List<WarehouseTransferPallet> pallets) {
if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete");
List<DerivedPalletAllocation> result = new ArrayList<>(); List<DerivedPalletAllocation> result = new ArrayList<>();
int itemIndex = 0; Set<Long> palletIds = pallets.stream().map(WarehouseTransferPallet::getId).collect(Collectors.toSet());
int itemRemaining = items.get(0).getPackageCount(); if (cartons.stream().anyMatch(box -> box.getTransferPalletId() == null || !palletIds.contains(box.getTransferPalletId())))
for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { throw conflict("每个实收箱必须分配到本单托盘");
int palletRemaining = pallets.get(palletIndex).getCartonCount(); for (int index = 0; index < pallets.size(); index++) {
while (palletRemaining > 0) { var pallet = pallets.get(index);
WarehouseTransferItem item = items.get(itemIndex); Map<Long, Integer> quantities = new java.util.LinkedHashMap<>();
if (itemIndex + 1 == items.size()) { cartons.stream().filter(box -> pallet.getId().equals(box.getTransferPalletId()))
result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining)); .forEach(box -> quantities.merge(box.getTransferItemId(), 1, Integer::sum));
break; if (quantities.values().stream().mapToInt(Integer::intValue).sum() != pallet.getCartonCount())
} throw conflict("托盘箱数与分配的实收箱不一致");
int allocated = Math.min(palletRemaining, itemRemaining); for (var entry : quantities.entrySet()) result.add(new DerivedPalletAllocation(index, entry.getKey(), entry.getValue()));
result.add(new DerivedPalletAllocation(palletIndex, item.getId(), allocated));
palletRemaining -= allocated;
itemRemaining -= allocated;
if (itemRemaining == 0 && itemIndex + 1 < items.size()) {
itemIndex++;
itemRemaining = items.get(itemIndex).getPackageCount();
}
}
} }
return result; return result;
} }
@@ -1796,18 +1887,17 @@ public class WarehouseTransferService {
pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED"); pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm()); pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm());
pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet); pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet);
for (WarehouseTransferPalletItem sourceItem : palletItems.stream() // Plan allocations are unique by inbound line + pallet. SKU breakdown
.filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) { // belongs to transfer pallet items and must not duplicate that allocation.
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); int receivedPackages = palletItems.stream().filter(row -> row.getTransferPalletId().equals(source.getId()))
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); .mapToInt(WarehouseTransferPalletItem::getPackageCount).sum();
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); if (receivedPackages != source.getCartonCount()) throw conflict("托盘明细箱数与实收不一致");
allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount()); InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
// PDA 已完成测量、打托并确认了本托箱数;入仓计划列表的“实际拣货数” allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
// 直接汇总该字段,不能再保留初始化时的 0。 allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
allocation.setPickedPackageCount(sourceItem.getPackageCount()); allocation.setPlannedPackageCount(receivedPackages); allocation.setCountedPackageCount(receivedPackages);
allocation.setActualVolumeCbm(source.getVolumeCbm()); allocation.setPickedPackageCount(receivedPackages); allocation.setActualVolumeCbm(source.getVolumeCbm());
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation); allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
}
} }
plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size()); plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size());
plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
@@ -1879,6 +1969,7 @@ public class WarehouseTransferService {
item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId);
item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED")); item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED"));
item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物")); item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物"));
item.setDeclaredQuantity(source.quantity());
item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now);
transferItems.insert(item); transferItems.insert(item);
return item.getId(); return item.getId();
@@ -683,7 +683,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE 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_ll_inbound_carton_mark(customer_id,carton_mark), PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
@@ -2647,15 +2647,19 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON', order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID', return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID',
sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数', available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组', completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id), KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; ) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
@@ -2663,6 +2667,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
@@ -3090,7 +3095,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item ( CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量', id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0) PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。 -- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement ( CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
@@ -4026,6 +4031,37 @@ CREATE TABLE ll_selfop_product (
KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id) KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
-- 历史库存基线;执行前必须取得指定环境的建表授权。 -- 历史库存基线;执行前必须取得指定环境的建表授权。
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow ( CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识', id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
@@ -9,9 +9,44 @@ info:
photo sets are immutable; identical replay does not deduct stock or create evidence again. photo sets are immutable; identical replay does not deduct stock or create evidence again.
Platform and merchant details expose only submitted photos, including photos of completed Platform and merchant details expose only submitted photos, including photos of completed
pallets while other pallets are still pending. Historic applications are not backfilled. pallets while other pallets are still pending. Historic applications are not backfilled.
Approval atomically creates one DISCARD_OUTBOUND document and planned SKU/pallet snapshots.
Platform/merchant summary and PDA pallet task include nullable outboundNo. PDA completion
synchronizes actual quantities and OUTBOUNDED status without a second inventory deduction.
Cancellation must use the discard application workflow. PDA resolve optionally accepts
discardNo together with palletNo; ambiguous scans require selection of a discard application.
Candidates include INVENTORY positions from every document type with available warehouse stock,
plus DROPSHIP and RETURN_CARTON sources. Create items identify exactly one of orderId,
returnCartonId or inventoryPositionId and carry selected cartonIds for partial whole-carton disposal; quantities must match those IDs.
RETURN_CARTON selections consume every SKU in the selected physical carton.
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
Stock without complete carton/pallet ownership remains available for full disposal only.
PDA tasks return scanMode PALLET or CARTON and the real cartonMarks for loose stock.
Bound pallets require their pallet number; loose stock requires every distinct carton mark scanned,
or confirmAllCartons=true. The loose group uses 散箱 as its API path key, never as a scan code.
The application may select a subset of available cartons. PDA must finish every selected carton;
short actual quantities are rejected. At least one photo remains required.
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
PDA completion leaves the application in PDA_COMPLETED (waiting for customer confirmation).
Only the authenticated merchant may confirm completion after reviewing submitted PDA photos.
Confirmation changes the application to COMPLETED, records customerConfirmedAt, and never deducts
inventory again. Identical repeated confirmation preserves the first timestamp and history.
Platform users cannot close the application on behalf of the customer.
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/merchant/v1/last-leg/cargo-discards/{discardNo}/confirm-completion:
parameters:
- $ref: '#/components/parameters/DiscardNo'
post:
summary: 客户确认废弃完结
description: >-
Customer scope is taken only from the authenticated merchant token.
Requires PDA_COMPLETED; repeated COMPLETED calls return the existing detail.
No request customerId, stock adjustment, or new photo upload is accepted.
The client shows submitted PDA photos before the customer confirms.
x-permissions: [merchant:overseas-warehouse:discard:confirm]
responses:
'200': { description: ApiResponse with updated Detail; summary contains customerConfirmedAt }
/api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/photos/files: /api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/photos/files:
parameters: parameters:
- $ref: '#/components/parameters/DiscardNo' - $ref: '#/components/parameters/DiscardNo'
@@ -68,7 +103,16 @@ paths:
required: [itemId, actualPackages] required: [itemId, actualPackages]
properties: properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' } itemId: { type: string, pattern: '^[1-9][0-9]*$' }
actualPackages: { type: integer, minimum: 0 } actualPackages: { type: integer, minimum: 1, description: Must equal plannedPackages; must finish the application selection }
scannedCartonMarks:
type: array
uniqueItems: true
items: { type: string }
description: All task cartonMarks must be scanned unless confirmAllCartons is true.
confirmAllCartons:
type: boolean
default: false
description: Explicitly confirm all loose cartons without individual scans; photos remain mandatory.
photoFileIds: photoFileIds:
type: array type: array
minItems: 1 minItems: 1
@@ -4,6 +4,67 @@ info:
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
version: 1.0.0 version: 1.0.0
paths: paths:
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/carton-label:
get:
summary: PDA读取转运箱标签
description: 需要 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR;只可读取Token绑定仓的转运单标签,包含新增实收箱号。
parameters:
- {name: transferNo, in: path, required: true, schema: {type: string}}
responses:
'200': {description: 统一业务响应,成功data为包含printableHtml的箱标签}
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts:
post:
summary: 先创建转运签收托盘草稿
description: 仅绑定仓PDA可操作,提交幂等键创建空托盘草稿,再调用generate生成实物托盘号。不接收手工箱数、尺寸、体积和重量,箱子清点时按归属自动汇总。
parameters:
- {name: transferNo, in: path, required: true, schema: {type: string}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [idempotencyKey]
properties:
palletId: {type: integer, format: int64, nullable: true}
idempotencyKey: {type: string, maxLength: 255}
responses:
'200': {description: 统一业务响应,data为含空托盘草稿的转运详情}
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/cartons/count:
post:
summary: 转运逐箱测量与清点
description: 仅限 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR,绑定仓由统一Token确定。先创建本单托盘号,每箱选择所属托盘并按申报SKU记录实点件数,托盘体积重量由分配箱子汇总,全部实收箱子测量清点后才可完成打托入库,打托箱数与已清点实收箱数核平;短收箱保留未收到事实,超收生成系统箱号;差异需原因。事务锁定转运单;原键原载荷重试不重复写入,同键不同载荷拒绝,已确认事实不可覆盖。
parameters:
- {name: transferNo, in: path, required: true, schema: {type: string}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [idempotencyKey, boxes]
properties:
idempotencyKey: {type: string, maxLength: 255}
discrepancyReason: {type: string, maxLength: 1000, description: 短收超收时必填差异原因}
boxes:
type: array
minItems: 1
maxItems: 5000
items:
type: object
required: [transferPalletId, length, width, height, weight, skuCode, quantity]
properties:
cartonMark: {type: string, maxLength: 255, nullable: true, description: 已有箱号;新增超收箱不传,由服务端生成}
transferItemId: {type: integer, format: int64, nullable: true, description: 新增超收箱所属本单申报明细}
transferPalletId: {type: integer, format: int64, description: 本单已生成的转运托盘事实ID,逐箱关联,托盘箱数体积重量由箱子汇总}
length: {type: number, minimum: 0.01, description: 箱长厘米}
width: {type: number, minimum: 0.01, description: 箱宽厘米}
height: {type: number, minimum: 0.01, description: 箱高厘米}
weight: {type: number, minimum: 0.01, description: 箱重千克}
skuCode: {type: string, maxLength: 255}
quantity: {type: integer, minimum: 1, maximum: 2147483647, description: 箱内实点件数}
responses:
'200': {description: 统一业务响应,成功data为转运详情,cartons含逐箱尺寸重量件数及清点操作人时间}
/api/platform/v1/last-leg/inventory/balances/{balanceId}: /api/platform/v1/last-leg/inventory/balances/{balanceId}:
get: get:
summary: 查询单条库存余额详情 summary: 查询单条库存余额详情
@@ -1214,7 +1275,7 @@ paths:
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } } responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
post: post:
summary: 客户创建仓储转运单 summary: 客户创建仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。 description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。货物 items[].quantity 为 SKU 申报件数(正整数),items[].packageCount 为预计箱数,整单 packageCount 等于明细预计箱数合计;件数与箱数独立,实际签收箱数和每箱件数以 PDA 清点事实为准,支持短收、超收并记录差异原因。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } } responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}: /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
@@ -1883,6 +1944,15 @@ components:
description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。 description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。
required: [receiptCommands] required: [receiptCommands]
properties: properties:
packageCount: { type: integer, description: 下单预计箱数,不随实际签收覆盖 }
items:
type: array
items:
type: object
properties:
quantity: { type: integer, format: int64, nullable: true, description: SKU 申报件数,历史未申报为 null }
packageCount: { type: integer, description: 该 SKU 预计箱数 }
cartons: { type: array, items: { type: object }, description: 实际签收仅统计状态 COUNTED 的箱子 }
receiptCommands: receiptCommands:
type: array type: array
items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' } items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' }
@@ -3214,7 +3284,7 @@ components:
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 } chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 } idempotencyKey: { type: string, maxLength: 255 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 } deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 } printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 }
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } } items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
RelabelItem: RelabelItem:
@@ -415,9 +415,10 @@ paths:
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete: /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
post: post:
summary: 提交现场任务到复核池并直接等待客户确认 summary: 提交现场任务到复核池并直接等待客户确认
description: 仓储转运换标须先确认所有涉及托盘的有效实测尺寸和重量,含拆托后原托盘剩余货物;手动完成也不能跳过实测校验。缺失或库存版本变化导致实测失效时,保持处理中并提示补测。
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } } responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 托盘实测缺失或失效、箱数未核平、照片或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review: /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
post: post:
summary: 已停用;客户确认完成复核,退回重贴由平台执行 summary: 已停用;客户确认完成复核,退回重贴由平台执行
@@ -672,6 +672,16 @@ class DeliveryTransferServiceTest {
assertEquals(4, detail.pallets().get(0).packageCount()); assertEquals(4, detail.pallets().get(0).packageCount());
} }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"DISCARD_OUTBOUND", "ONE_CLICK_DROPSHIP"})
void discardDoesNotExposeHistoricInboundCodeAsLxButDropshipKeepsRealLx(String type) {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301");
outbound.setOutboundType(type); outbound.setLxCodes("LX-REAL-001");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
var detail = service.outboundDetail("OUT-301");
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
}
@Test @Test
void dispatchOutboundStillRequiresItsDeliveryDocument() { void dispatchOutboundStillRequiresItsDeliveryDocument() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
@@ -3,10 +3,51 @@ package com.baiyue.lastleg.discard;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.sql.DriverManager;
import java.sql.SQLException;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import org.apache.ibatis.annotations.Update;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CargoDiscardSchemaContractTest { class CargoDiscardSchemaContractTest {
@Test
void returnCartonDeductionAllowsZeroButRejectsNegativeStock() throws Exception {
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
var constraint = java.util.regex.Pattern.compile(
"CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK\\(([^)]+)\\)")
.matcher(baseline);
assertTrue(constraint.find());
String deduction = ReturnInventoryCartonItemMapper.class.getMethod("deduct", long.class, long.class)
.getAnnotation(Update.class).value()[0]
.replace("#{quantity}", "?").replace("#{id}", "?");
try (var connection = DriverManager.getConnection("jdbc:h2:mem:discard_zero_quantity;MODE=MySQL")) {
try (var statement = connection.createStatement()) {
statement.execute("CREATE TABLE ll_return_inventory_carton_item (id BIGINT PRIMARY KEY, "
+ "quantity BIGINT NOT NULL, CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK("
+ constraint.group(1) + "))");
statement.execute("INSERT INTO ll_return_inventory_carton_item VALUES (1, 3)");
try (var update = connection.prepareStatement(deduction)) {
update.setLong(1, 3); update.setLong(2, 1); update.setLong(3, 3);
assertEquals(1, update.executeUpdate());
update.setLong(1, 1); update.setLong(3, 1);
assertEquals(0, update.executeUpdate());
}
try (var result = statement.executeQuery("SELECT quantity FROM ll_return_inventory_carton_item WHERE id=1")) {
assertTrue(result.next());
assertEquals(0, result.getLong(1));
}
assertThrows(SQLException.class, () -> statement.executeUpdate(
"UPDATE ll_return_inventory_carton_item SET quantity=-1 WHERE id=1"));
}
}
}
@Test @Test
void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception { void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception {
String sql; String sql;
@@ -15,6 +15,22 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class MerchantCargoDiscardControllerTest { class MerchantCargoDiscardControllerTest {
@Test
void completionUsesAuthenticatedCustomerAndIndependentPermission() throws Exception {
controller.confirmCompletion(user(100L, "MERCHANT"), "CD001");
verify(service).confirmCompletion("100", "CD001", "1");
assertThrows(ResponseStatusException.class,
() -> controller.confirmCompletion(user(0L, "PLATFORM"), "CD001"));
var guard = MerchantCargoDiscardController.class.getMethod("confirmCompletion", AuthenticatedUser.class, String.class)
.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class);
org.junit.jupiter.api.Assertions.assertEquals(
"hasAuthority('merchant:overseas-warehouse:discard:confirm')", guard.value());
org.junit.jupiter.api.Assertions.assertFalse(java.util.Arrays.stream(CargoDiscardController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class))
.flatMap(method -> java.util.Arrays.stream(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()))
.anyMatch(path -> path.endsWith("/complete") || path.endsWith("/confirm-completion")));
}
private final CargoDiscardService service = mock(CargoDiscardService.class); private final CargoDiscardService service = mock(CargoDiscardService.class);
private final MerchantCargoDiscardController controller = controller(); private final MerchantCargoDiscardController controller = controller();
private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query( private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query(
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() { @BeforeEach void setup() {
service = new PdaCargoDiscardService(); service = new PdaCargoDiscardService();
ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class));
ReflectionTestUtils.setField(service, "photoService", photoService); ReflectionTestUtils.setField(service, "photoService", photoService);
ReflectionTestUtils.setField(service, "applications", applications); ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "items", items);
@@ -122,8 +123,9 @@ class PdaCargoDiscardServiceTest {
@Test void loadedForeignCustomerItemCannotRecover() { finished("COMPLETED"); item.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } @Test void loadedForeignCustomerItemCannotRecover() { finished("COMPLETED"); item.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
@Test void loadedDifferentApplicationItemCannotRecover() { finished("COMPLETED"); item.setApplicationId(2L); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } @Test void loadedDifferentApplicationItemCannotRecover() { finished("COMPLETED"); item.setApplicationId(2L); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
@ParameterizedTest @ValueSource(ints = {3, 1, 0}) @Test
void threeLocalOrdersUploadConfirmAndReplayWithFullPartialAndZeroActualDisposal(int actual) { void fullDisposalUploadsConfirmsAndReplaysWithoutDoubleDeduction() {
int actual = 3;
var realPhotos = new com.baiyue.lastleg.discard.service.CargoDiscardPhotoService(); var realPhotos = new com.baiyue.lastleg.discard.service.CargoDiscardPhotoService();
var photoMapper = mock(com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper.class); var photoMapper = mock(com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper.class);
var photoFiles = mock(com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.class); var photoFiles = mock(com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.class);
@@ -151,4 +153,56 @@ class PdaCargoDiscardServiceTest {
verify(photoMapper, times(1)).updateById(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class)); verify(photoMapper, times(1)).updateById(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class));
assertThrows(ResponseStatusException.class, () -> service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1}))); assertThrows(ResponseStatusException.class, () -> service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1})));
} }
@ParameterizedTest @ValueSource(ints = {0, 1, 2})
void partialAndZeroDisposalDoNotWriteInventoryOrPhotos(int actual) {
item.setDiscardPackages(3);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual)));
noWrites(); verifyNoInteractions(photoService);
}
private void looseTask() {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(2);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setId(42L); position.setCustomerId("CUSTOMER"); position.setOverseasWarehouseId("99");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
when(stockService.cartonMarks(item)).thenReturn(List.of("BOX-1", "BOX-2"));
}
private PdaCargoDiscardDtos.Confirm looseRequest(List<String> marks, boolean bulk) {
return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk);
}
@Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() {
looseTask(); when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "BOX-1", "CD-1");
assertEquals("CARTON", task.scanMode()); assertEquals(List.of("BOX-1", "BOX-2"), task.cartonMarks());
for (var marks : List.of(List.of("BOX-1"), List.of("BOX-1", "BOX-1"), List.of("BOX-1", "OTHER")))
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "散箱", looseRequest(marks, false)));
noWrites(); verifyNoInteractions(photoService);
}
@ParameterizedTest @ValueSource(booleans = {false, true})
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) {
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk);
assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status());
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
service.confirm(identity, "CD-1", "散箱", request);
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(2), eq("OP"), any());
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "P-42", "CD-1");
assertEquals("CARTON", task.scanMode()); assertEquals("P-42", task.palletNo());
assertEquals(List.of("BOX-1", "BOX-2"), task.cartonMarks());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1"), false)));
verifyNoInteractions(adjustments);
service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1", "BOX-2"), false));
service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1", "BOX-2"), false));
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(2), eq("OP"), any());
}
} }
@@ -0,0 +1,107 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class CargoDiscardOutboundServiceTest {
private final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class);
private final OutboundPalletItemMapper pallets = mock(OutboundPalletItemMapper.class);
private final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class);
private final CargoDiscardCartonMapper cartons = mock(CargoDiscardCartonMapper.class);
private final DropshipOrderProfileMapper profiles = mock(DropshipOrderProfileMapper.class);
private final ReturnInventoryCartonMapper returnCartons = mock(ReturnInventoryCartonMapper.class);
private CargoDiscardOutboundService service;
private CargoDiscardApplication application;
private CargoDiscardItem first, second;
private OutboundOrder document;
private final List<OutboundPalletItem> snapshots = new ArrayList<>();
@BeforeEach void setup() {
service = new CargoDiscardOutboundService();
ReflectionTestUtils.setField(service, "outbounds", outbounds);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "profiles", profiles);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1");
application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99");
application.setTotalPackages(5); application.setStatus("APPROVED");
application.setInboundCodes("RT-123,RK-456");
first = line(11L, 21L, 3); second = line(12L, 22L, 2);
when(items.listApplication("C1", 1L)).thenReturn(List.of(first, second));
when(cartons.listApplication("C1", 1L)).thenReturn(List.of());
for (long id : List.of(21L, 22L)) {
var profile = new DropshipOrderProfile(); profile.setId(id); profile.setCustomerId("C1"); profile.setPalletNo("P-" + id);
when(profiles.selectById(id)).thenReturn(profile);
}
when(outbounds.findByIdempotency("C1", "CARGO_DISCARD:1")).thenAnswer(i -> document);
when(outbounds.lockByIdempotency("C1", "CARGO_DISCARD:1")).thenAnswer(i -> document);
when(outbounds.insert(any(OutboundOrder.class))).thenAnswer(i -> { document = i.getArgument(0); return 1; });
when(pallets.insert(any(OutboundPalletItem.class))).thenAnswer(i -> { snapshots.add(i.getArgument(0)); return 1; });
when(pallets.findByOutbound(eq("C1"), anyLong())).thenAnswer(i -> snapshots);
}
private CargoDiscardItem line(long id, long profile, int quantity) {
var line = new CargoDiscardItem(); line.setId(id); line.setApplicationId(1L); line.setCustomerId("C1");
line.setProfileId(profile); line.setDiscardPackages(quantity); line.setSkuCode("SKU-" + id); return line;
}
@Test void approvalCreatesExactlyOneDocumentAndImmutablePlannedLines() {
service.create(application, "OP"); service.create(application, "OP");
assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus());
assertNull(document.getLxCodes());
assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount());
assertTrue(service.outboundNo(application).startsWith("OB-"));
assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList());
verify(outbounds, times(1)).insert(any(OutboundOrder.class)); verify(pallets, times(2)).insert(any(OutboundPalletItem.class));
}
@Test void partialThenFinalCompletionTracksActualsWithoutChangingPlannedQuantities() {
service.create(application, "OP"); first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
service.sync(application); assertEquals("PICKING", document.getStatus());
assertEquals(2, snapshots.get(0).getPickedPackageCount()); assertEquals(0, snapshots.get(1).getPickedPackageCount());
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(0);
application.setStatus("PDA_COMPLETED"); application.setTotalPackages(2); service.sync(application);
assertEquals("OUTBOUNDED", document.getStatus()); assertEquals(2, document.getPackageCount());
assertEquals(3, snapshots.get(0).getPackageCount()); assertEquals("PICKED", snapshots.get(1).getStatus());
application.setStatus("COMPLETED"); service.sync(application); assertEquals("OUTBOUNDED", document.getStatus());
}
@Test void cancellationSynchronizesDocumentAndEveryLine() {
service.create(application, "OP"); application.setStatus("CANCELLED"); service.sync(application);
assertEquals("CANCELLED", document.getStatus()); assertTrue(snapshots.stream().allMatch(s -> "CANCELLED".equals(s.getStatus())));
}
@Test void syncClearsHistoricallyMislabelledInboundCodesWithoutChangingSourceSnapshots() {
first.setInboundCode("RK-456");
service.create(application, "OP"); document.setLxCodes("RT-123,RK-456");
service.sync(application);
assertNull(document.getLxCodes()); assertEquals("RK-456", snapshots.get(0).getInboundCode());
assertEquals("RT-123,RK-456", application.getInboundCodes());
}
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1");
carton.setOverseasWarehouseId("99"); carton.setCartonMark("BOX-31");
when(returnCartons.selectById(31L)).thenReturn(carton);
service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("BOX-31", snapshots.get(0).getCartonMarks());
assertEquals(3L, snapshots.get(0).getReturnSkuQuantity());
}
@Test void invalidSourcePreventsAnyOutboundInsert() {
when(profiles.selectById(22L)).thenReturn(null);
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
}
}
@@ -47,6 +47,36 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class CargoDiscardServiceTest { class CargoDiscardServiceTest {
@Test
void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() {
CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(1001L); application.setCustomerId("100"); application.setDiscardNo("CD001");
application.setTotalPackages(1); application.setOverseasWarehouseName("测试仓"); application.setStatus(CargoDiscardService.PDA_COMPLETED);
storedApplication.set(application);
when(applications.lockNo("100", "CD001")).thenReturn(application);
service.confirmCompletion("100", "CD001", "CUSTOMER");
var confirmedAt = application.getCustomerConfirmedAt();
org.junit.jupiter.api.Assertions.assertNotNull(confirmedAt);
assertEquals(CargoDiscardService.COMPLETED, application.getStatus());
assertEquals("客户确认废弃完成", storedHistories.get(0).getRemark());
service.confirmCompletion("100", "CD001", "CUSTOMER");
assertEquals(confirmedAt, application.getCustomerConfirmedAt());
assertEquals(1, storedHistories.size());
verify(applications, times(1)).updateById(application);
org.mockito.Mockito.verifyNoInteractions(adjustments, inventory, returnCartonItems);
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"PENDING_REVIEW", "APPROVED", "REJECTED", "CANCELLED"})
void customerCannotConfirmBeforePdaCompletion(String status) {
CargoDiscardApplication application = new CargoDiscardApplication(); application.setStatus(status);
when(applications.lockNo("100", "CD001")).thenReturn(application);
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
() -> service.confirmCompletion("100", "CD001", "CUSTOMER"));
verify(applications, never()).updateById(any(CargoDiscardApplication.class));
}
@Mock CargoDiscardApplicationMapper applications; @Mock CargoDiscardApplicationMapper applications;
@Mock CargoDiscardItemMapper items; @Mock CargoDiscardItemMapper items;
@Mock CargoDiscardCartonMapper discardCartons; @Mock CargoDiscardCartonMapper discardCartons;
@@ -57,8 +87,10 @@ class CargoDiscardServiceTest {
@Mock LastlegOrderStatusHistoryMapper orderHistories; @Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock ReturnInventoryCartonMapper returnCartons; @Mock ReturnInventoryCartonMapper returnCartons;
@Mock ReturnInventoryCartonItemMapper returnCartonItems; @Mock ReturnInventoryCartonItemMapper returnCartonItems;
@Mock com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory;
private CargoDiscardService service; private CargoDiscardService service;
private final CargoDiscardOutboundService outboundService = org.mockito.Mockito.mock(CargoDiscardOutboundService.class);
private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>(); private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>();
private final List<CargoDiscardItem> storedItems = new ArrayList<>(); private final List<CargoDiscardItem> storedItems = new ArrayList<>();
private final List<CargoDiscardCarton> storedLinks = new ArrayList<>(); private final List<CargoDiscardCarton> storedLinks = new ArrayList<>();
@@ -67,6 +99,11 @@ class CargoDiscardServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new CargoDiscardService(); service = new CargoDiscardService();
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
ReflectionTestUtils.setField(service, "outboundService", outboundService);
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class)); ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
ReflectionTestUtils.setField(service, "applications", applications); ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "items", items);
@@ -94,18 +131,20 @@ class CargoDiscardServiceTest {
@Test @Test
void createReservesExactSkuCartonsWithoutDeductingInventory() { void createReservesExactSkuCartonsWithoutDeductingInventory() {
LastlegOrder order = order(); LastlegOrder order = order();
order.setOverseasWarehouseId("99"); order.setOverseasWarehouseName(null);
DropshipSkuInventory sku = skuInventory(); DropshipSkuInventory sku = skuInventory();
when(orders.selectById(101L)).thenReturn(order); when(orders.selectById(101L)).thenReturn(order);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L); when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L))); when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku); when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L); when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(2L);
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1); when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create( var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged")); List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged"));
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status()); assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
assertEquals("洛杉矶仓", storedApplication.get().getOverseasWarehouseName());
assertEquals(2, detail.summary().totalPackages()); assertEquals(2, detail.summary().totalPackages());
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee()); assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
assertEquals(2, storedLinks.size()); assertEquals(2, storedLinks.size());
@@ -113,6 +152,38 @@ class CargoDiscardServiceTest {
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
} }
@Test void historicalDiscardDetailResolvesMissingWarehouseNameByOperatingWarehouseId() {
CargoDiscardApplication application = pendingApplication();
application.setOverseasWarehouseId("99"); application.setOverseasWarehouseName(null);
storedApplication.set(application);
assertEquals("洛杉矶仓", service.detail("100", application.getDiscardNo()).summary().overseasWarehouseName());
verify(applications, never()).updateById(any(CargoDiscardApplication.class));
}
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
when(orders.selectById(101L)).thenReturn(order());
when(inventory.lockSelected("100", 101L, "SKU-1", List.of(2L))).thenReturn(List.of(carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(skuInventory());
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.availableOrderCartons("100", 101L)).thenReturn(List.of(carton(1L), carton(2L), carton(3L)));
when(inventory.changeCartonStatus("100", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 1, null, List.of(2L))), "partial-create", "damage"));
assertEquals(1, detail.summary().totalPackages()); assertEquals(new BigDecimal("5.00"), detail.summary().totalFee());
assertEquals(List.of("C-2"), detail.items().get(0).cartonMarks());
assertEquals(1, storedLinks.size()); assertEquals(2L, storedLinks.get(0).getCartonId());
org.junit.jupiter.api.Assertions.assertTrue(storedItems.get(0).getCartonScanRequired());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(1L), any(), any());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(3L), any(), any());
}
@Test void duplicatePhysicalBoxAcrossSkuLinesIsRejectedBeforeCreation() {
when(orders.selectById(101L)).thenReturn(order());
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-A", 1, null, List.of(2L)),
new CargoDiscardRequests.Item(101L, null, "SKU-B", 1, null, List.of(2L))), "duplicate-box", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test @Test
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() { void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L); CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
@@ -132,7 +203,7 @@ class CargoDiscardServiceTest {
ReturnInventoryCarton carton = new ReturnInventoryCarton(); ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(901L); carton.setId(901L);
carton.setCustomerId("100"); carton.setCustomerId("100");
carton.setOverseasWarehouseId("WH-1"); carton.setOverseasWarehouseId("99");
carton.setSourceReturnNo("RT-20260928-001"); carton.setSourceReturnNo("RT-20260928-001");
carton.setCartonMark("RC-LAX-001"); carton.setCartonMark("RC-LAX-001");
carton.setStatus("AVAILABLE"); carton.setStatus("AVAILABLE");
@@ -149,6 +220,7 @@ class CargoDiscardServiceTest {
assertEquals(1, result.size()); assertEquals(1, result.size());
assertEquals("RETURN_CARTON", result.get(0).sourceType()); assertEquals("RETURN_CARTON", result.get(0).sourceType());
assertEquals("洛杉矶仓", result.get(0).overseasWarehouseName());
assertEquals(901L, result.get(0).returnCartonId()); assertEquals(901L, result.get(0).returnCartonId());
assertEquals("RC-LAX-001", result.get(0).cartonMark()); assertEquals("RC-LAX-001", result.get(0).cartonMark());
assertEquals(4, result.get(0).availablePackages()); assertEquals(4, result.get(0).availablePackages());
@@ -167,6 +239,7 @@ class CargoDiscardServiceTest {
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok")); var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
assertEquals(CargoDiscardService.APPROVED, detail.summary().status()); assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
verify(outboundService).create(application, "9");
verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any()); verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any());
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
assertEquals("ok", storedHistories.get(storedHistories.size() - 1).getRemark()); assertEquals("ok", storedHistories.get(storedHistories.size() - 1).getRemark());
@@ -0,0 +1,132 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class CargoDiscardStockServiceTest {
final CargoDiscardInventoryMapper stock = mock(CargoDiscardInventoryMapper.class);
final InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
final InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
final CargoDiscardCartonMapper links = mock(CargoDiscardCartonMapper.class);
final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class);
final InventoryCommandService commands = mock(InventoryCommandService.class);
final CargoDiscardStockService service = new CargoDiscardStockService();
final InventoryPosition position = new InventoryPosition();
final InventoryBalance balance = new InventoryBalance();
final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "stock", stock);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "links", links);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L);
position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L);
balance.setId(51L); balance.setCustomerId("C1"); balance.setOverseasWarehouseId("99");
balance.setInboundCode("WT-WH-1"); balance.setInboundOrderNo("WT-WH-1"); balance.setSkuCode("SKU"); balance.setAvailablePackages(100L);
application.setId(11L); application.setDiscardNo("CD-1"); application.setCustomerId("C1"); application.setCreatedBy("OP");
when(positions.findTenantPosition("C1", 42L)).thenReturn(position);
when(stock.lockGenericPosition("C1", 42L)).thenReturn(position);
when(balances.selectById(51L)).thenReturn(balance);
}
@Test void warehouseTransferStockIsEligibleAndKeepsTrueSourcePosition() {
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
var candidate = service.candidates("C1", "WT").get(0);
assertEquals("INVENTORY", candidate.sourceType()); assertNull(candidate.orderId());
assertEquals(42L, candidate.inventoryPositionId()); assertEquals("WT-WH-1", candidate.inboundCode());
service.reserve("C1", "OP", application, request(100), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
var line = capture.getValue(); assertEquals(42L, line.getInventoryPositionId()); assertEquals(100, line.getDiscardPackages());
var move = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); verify(commands).hold(eq("C1"), eq("OP"), move.capture());
assertEquals(100, move.getValue().packages()); assertEquals("CARGO_DISCARD:11:" + line.getId(), move.getValue().sourceEventId());
service.release("C1", application, line); verify(commands).release(eq("C1"), eq("OP"), any());
service.discard("C1", "OP", application, line, 100); verify(commands).discardHeld(eq("C1"), eq("OP"), any());
}
@Test void partialForeignSkuAndDropshipBypassAreRejectedBeforeHolding() {
assertThrows(ResponseStatusException.class, () -> service.reserve("C1", "OP", application, request(99), LocalDateTime.now()));
assertThrows(ResponseStatusException.class, () -> service.validate("C2", request(100)));
assertThrows(ResponseStatusException.class, () -> service.validate("C1", new CargoDiscardRequests.Item(null, null, "OTHER", 100, 42L)));
when(stock.isDropshipPosition("C1", 42L)).thenReturn(1L);
assertThrows(ResponseStatusException.class, () -> service.validate("C1", request(100)));
verifyNoInteractions(commands, items);
}
@Test void looseStockRequiresCompleteRealMarksAndSnapshotsThem() {
position.setPalletNo(null); position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
when(stock.looseCartonMarks("C1", 42L)).thenReturn(List.of("BOX-1"));
assertThrows(ResponseStatusException.class, () -> service.reserve("C1", "OP", application, request(2), LocalDateTime.now()));
verifyNoInteractions(commands, items);
when(stock.looseCartonMarks("C1", 42L)).thenReturn(List.of("BOX-1", "BOX-2"));
service.reserve("C1", "OP", application, request(2), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
assertEquals(List.of("BOX-1", "BOX-2"), service.cartonMarks(capture.getValue()));
}
private com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow carton(long id, String mark) {
var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow();
carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton;
}
@Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() {
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
when(stock.changeGenericCartonStatus("C1", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var request = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L));
service.reserve("C1", "OP", application, request, LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
var line = capture.getValue(); assertEquals(1, line.getDiscardPackages()); assertTrue(line.getCartonScanRequired());
assertEquals(List.of("BOX-2"), service.cartonMarks(line));
var boxLink = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(boxLink.capture());
assertEquals(2L, boxLink.getValue().getCartonId());
verify(stock, never()).changeGenericCartonStatus(eq("C1"), eq(1L), anyString(), anyString());
verify(stock, never()).changeGenericCartonStatus(eq("C1"), eq(3L), anyString(), anyString());
when(links.listApplication("C1", 11L)).thenReturn(List.of(boxLink.getValue()));
when(stock.changeGenericCartonStatus("C1", 2L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1);
service.release("C1", application, line);
verify(stock).changeGenericCartonStatus("C1", 2L, "DISCARD_PENDING", "AVAILABLE");
}
@Test void partialBoxSelectionRejectsForeignRepeatedAndMismatchedQuantity() {
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
for (var request : List.of(new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(9L)),
new CargoDiscardRequests.Item(null, null, "SKU", 2, 42L, List.of(1L, 1L)),
new CargoDiscardRequests.Item(null, null, "SKU", 2, 42L, List.of(1L)))) {
assertThrows(ResponseStatusException.class, () -> service.validate("C1", request));
}
verifyNoInteractions(commands, items, links);
}
@Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() {
position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER");
var second = carton(2L, "WT-1#2-2"); second.setSourceType("TRANSFER");
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(first, second));
when(stock.changeTransferCartonStatus("C1", 2L, "EXPECTED", "DISCARD_PENDING")).thenReturn(1);
service.reserve("C1", "OP", application, new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L)), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture());
assertEquals("TRANSFER", capture.getValue().getSourceType());
when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue()));
when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED")).thenReturn(1);
var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture());
service.release("C1", application, line.getValue());
verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED");
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
}
}
@@ -594,6 +594,35 @@ class InventoryCommandServiceTest {
"return-1", List.of("FILE-1")); "return-1", List.of("FILE-1"));
} }
@Test void discardOnlyConsumesOwnedHoldAndKeepsAvailableStockConserved() {
var balance = balance(200L, 7); balance.setHoldPackages(3L);
var position = position(201L, 200L, 7); position.setHoldPackages(3L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
var hold = new InventoryLedger(); hold.setPositionId(201L); hold.setBusinessOrderNo("CD-1"); hold.setDeltaPackages(3L);
when(ledgers.orderHoldCycle("100", 101L, "CARGO_DISCARD:1:2")).thenReturn(List.of(hold));
when(commands.palletPackages(300L)).thenReturn(7L);
service.discardHeld("100", "OP", new InventoryCommandRequests.Move(201L, 3, "CD-1", "OB-1", "CARGO_DISCARD:1:2", "DISCARD-1", null));
assertEquals(7L, position.getAvailablePackages()); assertEquals(7L, balance.getAvailablePackages());
assertEquals(0L, position.getHoldPackages()); assertEquals(0L, balance.getHoldPackages());
var capture = ArgumentCaptor.forClass(InventoryLedger.class); verify(ledgers).insert(capture.capture());
assertEquals("DISCARD", capture.getValue().getBusinessType()); assertEquals(-3L, capture.getValue().getDeltaPackages());
assertEquals("OB-1", capture.getValue().getOutboundOrderNo());
verify(warehouseGateway, never()).scrapPallet(any(), org.mockito.ArgumentMatchers.anyLong());
}
@Test void foreignDiscardHoldCannotConsumeAnotherApplicationsStock() {
var balance = balance(200L, 0); balance.setHoldPackages(3L);
var position = position(201L, 200L, 0); position.setHoldPackages(3L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
var hold = new InventoryLedger(); hold.setPositionId(201L); hold.setBusinessOrderNo("OTHER-CD"); hold.setDeltaPackages(3L);
when(ledgers.orderHoldCycle("100", 101L, "CARGO_DISCARD:1:2")).thenReturn(List.of(hold));
assertThrows(ResponseStatusException.class, () -> service.discardHeld("100", "OP", new InventoryCommandRequests.Move(201L, 3, "CD-1", null, "CARGO_DISCARD:1:2", "DISCARD-1", null)));
assertEquals(3L, position.getHoldPackages());
verify(positions, never()).updateById(any(InventoryPosition.class)); verify(ledgers, never()).insert(any(InventoryLedger.class));
}
private InventoryBalance balance(Long id, long available) { private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance(); InventoryBalance value = new InventoryBalance();
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100"); value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
@@ -53,6 +53,7 @@ class PdaRelabelServiceTest {
@Mock RelabelApplicationService applicationService; @Mock RelabelApplicationService applicationService;
@Mock RelabelPhotoFileGateway photoFiles; @Mock RelabelPhotoFileGateway photoFiles;
@Mock com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions; @Mock com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
@Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements;
PdaRelabelService service; PdaRelabelService service;
RelabelApplication application; RelabelApplication application;
RelabelItem item; RelabelItem item;
@@ -62,6 +63,7 @@ class PdaRelabelServiceTest {
void setUp() { void setUp() {
service = new PdaRelabelService(); service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "skuConversions", skuConversions); ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements);
ReflectionTestUtils.setField(service, "applications", applications); ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class)); ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "items", items);
@@ -452,12 +454,44 @@ class PdaRelabelServiceTest {
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done")); new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
verify(photoFiles).validate("100", 20L, 501L); verify(photoFiles).validate("100", 20L, 501L);
verify(palletMeasurements).requireMeasured(application);
verify(skuConversions).apply(eq(application), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyList(), anyString(), org.mockito.ArgumentMatchers.anyList()); verify(skuConversions).apply(eq(application), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyList(), anyString(), org.mockito.ArgumentMatchers.anyList());
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus()); assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus()); assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any()); verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
} }
@Test
void missingOriginalRemainderMeasurementBlocksNormalAndManualCompletionBeforeWrites() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 1);
task.setRelabelledPackages(1);
persistedFacts.add(fact("RELABELLED"));
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var measurements = org.mockito.Mockito.spy(new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService());
var measuredNew = new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement(
200L, "NEW", 1, "2:1:1", java.math.BigDecimal.TEN, java.math.BigDecimal.TEN,
java.math.BigDecimal.TEN, java.math.BigDecimal.ONE, new java.math.BigDecimal("0.001"), false);
var unmeasuredRemainder = new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement(
100L, "OLD", 2, "1:3:2", null, null, null, null, null, false);
org.mockito.Mockito.doReturn(List.of(measuredNew, unmeasuredRemainder)).when(measurements).snapshots(application);
ReflectionTestUtils.setField(service, "palletMeasurements", measurements);
for (boolean manual : List.of(false, true)) {
var error = assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-" + manual, "done", manual)));
assertTrue(error.getReason().contains("托盘 OLD"));
assertEquals("PROCESSING", task.getStatus());
assertEquals("PROCESSING", application.getStatus());
}
verify(tasks, never()).updateWithVersion(any(), anyInt());
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
org.mockito.Mockito.verifyNoInteractions(applicationService, photoFiles, skuConversions);
verify(histories, never()).insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class));
verify(operations, never()).insert(any(PdaRelabelOperation.class));
}
@Test @Test
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() { void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1", assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1",
@@ -373,6 +373,47 @@ class ReturnInventoryServiceTest {
verify(balances, never()).adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong()); verify(balances, never()).adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong());
} }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(longs = {3L, 6L})
void mergeCoalescesTrackedAndOriginalRemainderOwnedByTheSameReturn(long quantity) {
var source = prepareConcurrentSource();
source.setTotalQuantity(6L);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L)));
when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of(
new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(quantity));
verify(packingTrace).insert(anyLong(), eq("10001"), anyLong(), eq("RC-SOURCE"),
eq("RC-TARGET"), eq("RT-1"), eq("SKU-A"), eq(quantity), any());
verify(packingTrace, org.mockito.Mockito.times(1)).insert(anyLong(), anyString(), anyLong(),
anyString(), anyString(), anyString(), anyString(), anyLong(), any());
assertThat(source.getTotalQuantity()).isEqualTo(6L - quantity);
}
@Test
void partialMergeStillRejectsActuallyDifferentReturnOwners() {
var source = prepareConcurrentSource();
source.setSourceReturnNo("RT-1,RT-2"); source.setTotalQuantity(6L);
var otherOrder = completedOrder(); otherOrder.setId(12L); otherOrder.setReturnNo("RT-2");
org.mockito.Mockito.reset(orders);
when(orders.findByNos("42", List.of("RT-1", "RT-2"))).thenReturn(List.of(completedOrder(), otherOrder));
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L)));
when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of(
new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L),
new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-2", 3L)));
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "操作员2", 42L),
new ReturnInventoryRequests.Merge(List.of("RT-1", "RT-2"),
List.of(new ReturnInventoryRequests.ValidItem("SKU-A", 3L)),
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, "mixed-owner-key")));
assertThat(error.getReason()).contains("不能自动猜测来源归属");
verify(packingTrace, never()).insert(anyLong(), anyString(), anyLong(), anyString(),
anyString(), anyString(), anyString(), anyLong(), any());
}
@Test @Test
void partiallyConsumedSourceUsesCurrentLockedSkuQuantityNotEarlierSnapshot() { void partiallyConsumedSourceUsesCurrentLockedSkuQuantityNotEarlierSnapshot() {
ReturnInventoryCarton locked = prepareConcurrentSource(); ReturnInventoryCarton locked = prepareConcurrentSource();
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
public class ReturnPackingTracePersistenceTest {
private JdbcTemplate jdbc;
private ReturnPackingTraceMapper mapper;
@BeforeEach
void setUp() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:packing-trace-" + UUID.randomUUID()
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'");
jdbc.execute("CREATE TABLE ll_return_inventory_operation (id BIGINT PRIMARY KEY, operation_type VARCHAR(40), result_json VARCHAR(1000))");
jdbc.execute("CREATE TABLE ll_return_packing_trace (id BIGINT PRIMARY KEY, customer_id VARCHAR(80), operation_id BIGINT, "
+ "source_carton_mark VARCHAR(80), target_carton_mark VARCHAR(80), return_no VARCHAR(80), sku_code VARCHAR(80), quantity BIGINT, created_at TIMESTAMP)");
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(ReturnPackingTraceMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ReturnPackingTraceMapper.class);
operation(1, "PLAN");
trace(1, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU", 3);
trace(2, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU", 3);
}
@Test
void allDetailQueriesSumHistoricalFragmentsWithoutChangingRawTrace() {
trace(3, 1, "C", "SOURCE-B", "TARGET", "RETURN-B", "SKU-B", 15);
assertThat(mapper.planHistory("C", "PLAN"))
.extracting(row -> row.skuCode(), row -> row.quantity())
.containsExactly(tuple("SKU", 6L), tuple("SKU-B", 15L));
assertThat(mapper.returnHistory("C", "RETURN")).singleElement()
.satisfies(row -> assertThat(row.quantity()).isEqualTo(6));
assertThat(mapper.listCartonHistory("C", "TARGET"))
.extracting(row -> row.skuCode(), row -> row.quantity())
.containsExactly(tuple("SKU-B", 15L), tuple("SKU", 6L));
assertThat(mapper.listReturn("C", "RETURN")).hasSize(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_packing_trace", Long.class)).isEqualTo(3);
}
@Test
void groupingKeepsOperationReturnSkuAndCartonBoundariesAndCustomerIsolation() {
operation(2, "PLAN"); operation(3, "OTHER-PLAN");
trace(3, 2, "C", "SOURCE", "TARGET", "RETURN", "SKU", 2);
trace(4, 3, "C", "SOURCE", "TARGET", "RETURN", "SKU", 1);
trace(5, 1, "C", "SOURCE", "TARGET", "RETURN-B", "SKU", 4);
trace(6, 1, "C", "SOURCE-B", "TARGET", "RETURN", "SKU", 5);
trace(7, 1, "C", "SOURCE", "TARGET-B", "RETURN", "SKU", 7);
trace(8, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU-B", 8);
trace(9, 1, "OTHER-CUSTOMER", "SOURCE", "TARGET", "RETURN", "SKU", 99);
trace(10, 1, "C", "", "TARGET", "RETURN", "SKU", 100);
assertThat(mapper.planHistory("C", "PLAN"))
.extracting(row -> row.sourceCartonMark(), row -> row.targetCartonMark(), row -> row.returnNo(), row -> row.skuCode(), row -> row.quantity())
.containsExactly(tuple("SOURCE", "TARGET", "RETURN", "SKU", 6L),
tuple("SOURCE", "TARGET", "RETURN", "SKU", 2L),
tuple("SOURCE", "TARGET", "RETURN-B", "SKU", 4L),
tuple("SOURCE-B", "TARGET", "RETURN", "SKU", 5L),
tuple("SOURCE", "TARGET-B", "RETURN", "SKU", 7L),
tuple("SOURCE", "TARGET", "RETURN", "SKU-B", 8L));
assertThat(mapper.returnHistory("C", "RETURN")).extracting(row -> row.quantity())
.containsExactly(6L, 2L, 1L, 5L, 7L, 8L);
assertThat(mapper.listCartonHistory("C", "TARGET")).extracting(row -> row.quantity())
.containsExactly(8L, 5L, 4L, 1L, 2L, 6L);
assertThat(mapper.planHistory("MISSING", "PLAN")).isEmpty();
assertThat(mapper.returnHistory("MISSING", "RETURN")).isEmpty();
assertThat(mapper.listCartonHistory("MISSING", "TARGET")).isEmpty();
}
private void operation(long id, String plan) {
jdbc.update("INSERT INTO ll_return_inventory_operation VALUES(?, 'MERGE', ?)", id, "{\"planNo\":\"" + plan + "\"}");
}
private void trace(long id, long operation, String customer, String source, String target, String owner, String sku, long quantity) {
jdbc.update("INSERT INTO ll_return_packing_trace VALUES(?,?,?,?,?,?,?,?,TIMESTAMP '2026-10-15 16:13:36.447')",
id, customer, operation, source, target, owner, sku, quantity);
}
public static String jsonExtract(String json, String path) throws Exception {
return new ObjectMapper().readTree(json).path(path.substring(2)).asText(null);
}
public static String jsonUnquote(String value) { return value; }
}
@@ -18,6 +18,16 @@ class WarehouseTransferCargoValidationTest {
} }
} }
@Test
void skuUnitsAndExpectedCartonsAreIndependent() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
assertTrue(validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 3, null, 120L)).isEmpty());
assertTrue(!validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 3, null, 0L)).isEmpty());
assertTrue(!validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 0, null, 120L)).isEmpty());
}
}
@Test @Test
void cartonRelabelAllowsOriginalSkuToBeOmitted() { void cartonRelabelAllowsOriginalSkuToBeOmitted() {
try (var factory = Validation.buildDefaultValidatorFactory()) { try (var factory = Validation.buildDefaultValidatorFactory()) {
@@ -0,0 +1,108 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.*;
import com.baiyue.lastleg.transfer.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class WarehouseTransferCartonCountTest {
WarehouseTransferService service;
WarehouseTransferOrderMapper orders = mock(WarehouseTransferOrderMapper.class);
WarehouseTransferCartonMapper cartons = mock(WarehouseTransferCartonMapper.class);
WarehouseTransferItemMapper items = mock(WarehouseTransferItemMapper.class);
WarehouseTransferPalletMapper palletMapper = mock(WarehouseTransferPalletMapper.class);
WarehouseTransferPallet pallet;
WarehouseTransferOrder order;
WarehouseTransferCarton a, b;
@BeforeEach void setup() {
service = spy(new WarehouseTransferService());
ReflectionTestUtils.setField(service, "transfers", orders);
ReflectionTestUtils.setField(service, "transferCartons", cartons);
ReflectionTestUtils.setField(service, "transferItems", items);
ReflectionTestUtils.setField(service, "transferPallets", palletMapper);
pallet = new WarehouseTransferPallet(); pallet.setId(200L); pallet.setStatus("GENERATED"); pallet.setWarehousePalletId(900L);
when(palletMapper.listOrder("C1", 1L)).thenReturn(List.of(pallet));
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
doReturn(null).when(service).detail(anyString(), anyString());
order = new WarehouseTransferOrder(); order.setId(1L); order.setCustomerId("C1"); order.setTransferNo("WT1"); order.setStatus("WAIT_PDA_RECEIVING"); order.setPackageCount(2);
a = box("BOX1", 10L); b = box("BOX2", 11L);
when(orders.lockForPda("WT1", "77")).thenReturn(order);
when(cartons.listOrder("C1", 1L)).thenReturn(List.of(a,b));
var ia = new WarehouseTransferItem(); ia.setId(10L); ia.setSkuCode("A");
var ib = new WarehouseTransferItem(); ib.setId(11L); ib.setSkuCode("B");
when(items.selectList(any())).thenReturn(List.of(ia,ib));
}
WarehouseTransferCarton box(String mark, long item) { var box = new WarehouseTransferCarton(); box.setCartonMark(mark); box.setCartonIndex((int)item); box.setTransferItemId(item); return box; }
WarehouseTransferRequests.CountCarton input(String mark, String sku, long quantity) { return new WarehouseTransferRequests.CountCarton(mark, null, 200L, new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"),sku,quantity); }
WarehouseTransferRequests.CountCartons request(String key, WarehouseTransferRequests.CountCarton... rows) { return new WarehouseTransferRequests.CountCartons(key,List.of(rows),null); }
@Test void multiSkuBoxesPersistDimensionsPiecesAndReplayWithoutWriting() {
var request = request("K1",input("BOX1","A",12),input("BOX2","B",7));
service.countCartons("OP1",77L,"WT1",request);
assertEquals(12L,a.getCountedQuantity()); assertEquals(7L,b.getCountedQuantity()); assertNotNull(a.getCountedAt()); assertEquals("OP1",a.getCountedBy()); assertEquals(new BigDecimal("2.5"),b.getGrossWeightKg());
assertEquals(200L,a.getTransferPalletId()); assertEquals(2,pallet.getCartonCount());
assertEquals(new BigDecimal("0.048000"),pallet.getVolumeCbm()); assertEquals(new BigDecimal("5.0"),pallet.getGrossWeightKg());
clearInvocations(cartons); order.setStatus("WAIT_CUSTOMER_CONFIRMATION");
service.countCartons("OP1",77L,"WT1",request);
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K1",input("BOX1","A",13),input("BOX2","B",7))));
}
@Test void missingDuplicateForeignSkuAndInvalidQuantityRejectBeforeWrites() {
var invalid = List.of(request("K",input("BOX1","A",1)),request("K",input("BOX1","A",1),input("BOX1","A",2)),request("K",input("OTHER","A",1),input("BOX2","B",2)),request("K",input("BOX1","B",1),input("BOX2","B",2)),request("K",input("BOX1","A",0),input("BOX2","B",2)));
for(var request:invalid) assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request));
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
}
@Test void shortReceiptNeedsReasonAndPreservesMissingBoxFact() {
var request = new WarehouseTransferRequests.CountCartons("SHORT",List.of(input("BOX1","A",3)),"少到一箱");
service.countCartons("OP1",77L,"WT1",request);
assertEquals("COUNTED",a.getStatus()); assertEquals("NOT_RECEIVED",b.getStatus());
assertEquals("少到一箱",b.getDiscrepancyReason()); assertNull(b.getCountedQuantity());
}
@Test void extraReceiptGeneratesOwnedBoxAndReconcilesToDeclaredSku() {
var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,"B",4);
service.countCartons("OP1",77L,"WT1",new WarehouseTransferRequests.CountCartons("EXTRA",List.of(input("BOX1","A",1),input("BOX2","B",2),extra),"多到一箱"));
var captured = org.mockito.ArgumentCaptor.forClass(WarehouseTransferCarton.class);
verify(cartons).insert(captured.capture());
assertEquals("WT1#EX-12",captured.getValue().getCartonMark()); assertEquals("C1",captured.getValue().getCustomerId()); assertEquals(4L,captured.getValue().getCountedQuantity());
}
@Test void cartonsRequireGeneratedPalletOwnedByThisOrder() {
when(palletMapper.listOrder("C1",1L)).thenReturn(List.of());
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2))));
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
when(palletMapper.listOrder("C1",1L)).thenReturn(List.of(pallet)); pallet.setStatus("DRAFT");
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2))));
}
@Test void multiSkuPalletCreatesOneInboundLineAllocationWithSummedBoxes() {
var plans = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper.class);
var lines = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper.class);
var pallets = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper.class);
var allocations = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper.class);
ReflectionTestUtils.setField(service,"inboundPlans",plans); ReflectionTestUtils.setField(service,"inboundPlanLines",lines);
ReflectionTestUtils.setField(service,"inboundPlanPallets",pallets); ReflectionTestUtils.setField(service,"inboundPlanAllocations",allocations);
order.setLastlegOrderId(9L); order.setInboundCode("WT1");
var plan = new com.baiyue.lastleg.inboundplan.entity.InboundPlan(); plan.setId(100L);
var line = new com.baiyue.lastleg.inboundplan.entity.InboundPlanLine(); line.setId(101L);
when(plans.findBySourceOrder("C1",9L)).thenReturn(plan); when(lines.selectOne(any())).thenReturn(line);
var pallet = new WarehouseTransferPallet(); pallet.setId(200L); pallet.setCartonCount(3); pallet.setVolumeCbm(BigDecimal.ONE); pallet.setSequenceNo(1);
var first = new WarehouseTransferPalletItem(); first.setTransferPalletId(200L); first.setTransferItemId(10L); first.setPackageCount(1);
var second = new WarehouseTransferPalletItem(); second.setTransferPalletId(200L); second.setTransferItemId(11L); second.setPackageCount(2);
ReflectionTestUtils.invokeMethod(service,"completeTransferInboundPlan","C1",order,List.of(pallet),List.of(first,second),java.time.LocalDateTime.now());
var result=org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class);
verify(allocations,times(1)).insert(result.capture()); assertEquals(3,result.getValue().getPickedPackageCount()); assertEquals(3,result.getValue().getCountedPackageCount());
}
@Test void otherWarehouseCannotReadOrWriteAndCompletedCountsCannotBeOverwritten() {
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",88L,"WT1",request("K",input("BOX1","A",1))));
service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2)));
clearInvocations(cartons);
assertThrows(ResponseStatusException.class,()->service.countCartons("OP2",77L,"WT1",request("NEW",input("BOX1","A",9),input("BOX2","B",2))));
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
}
}
@@ -48,19 +48,17 @@ class WarehouseTransferPalletAllocationTest {
} }
@Test @Test
void persistedOverReceiptUsesTheSameStableAllocationRule() { void countedBoxesDetermineSkuPlacementRatherThanDeclarationOrder() {
WarehouseTransferPallet first = persistedPallet(4); WarehouseTransferPallet first = persistedPallet(2); first.setId(100L);
WarehouseTransferPallet second = persistedPallet(3); WarehouseTransferPallet second = persistedPallet(1); second.setId(200L);
var a = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); a.setTransferItemId(12L); a.setTransferPalletId(100L);
var allocations = WarehouseTransferService.derivePersistedPalletAllocations( var b = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); b.setTransferItemId(12L); b.setTransferPalletId(100L);
List.of(item(11L, 3), item(12L, 2)), List.of(first, second)); var c = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); c.setTransferItemId(11L); c.setTransferPalletId(200L);
var allocations = WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second));
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum()); assertEquals(List.of(new WarehouseTransferService.DerivedPalletAllocation(0,12L,2),new WarehouseTransferService.DerivedPalletAllocation(1,11L,1)),allocations);
assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L) c.setTransferPalletId(999L);
.mapToInt(value -> value.packageCount()).sum()); org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L) () -> WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second)));
.mapToInt(value -> value.packageCount()).sum());
assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList());
} }
@Test @Test
@@ -322,6 +322,10 @@ class WarehouseTransferServiceTest {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
transfer.setOperatingWarehouseId("77"); transfer.setOperatingWarehouseId("77");
WarehouseTransferPallet pallet = measuredPallet("GENERATED"); WarehouseTransferPallet pallet = measuredPallet("GENERATED");
var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> {
var box = new WarehouseTransferCarton(); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box;
}).toList();
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted);
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5))); when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5)));
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
@@ -366,6 +370,10 @@ class WarehouseTransferServiceTest {
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
when(sourceOrders.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of());
var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> {
var box = new WarehouseTransferCarton(); box.setTransferPalletId(pallet.getId()); box.setTransferItemId(item.getId()); box.setCartonIndex(index); box.setTotalCartons(5); box.setCartonMark("BOX-" + index); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box;
}).toList();
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted);
var result = service.palletize("OP-1", 77L, "WTR-1", var result = service.palletize("OP-1", 77L, "WTR-1",
new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))); new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)));
+17
View File
@@ -1,7 +1,11 @@
# 后端脚本管理与全量 HTTP 验收索引 # 后端脚本管理与全量 HTTP 验收索引
`appointment-work-test-env.mjs`:预约事项与稳定批次 TEST 定向 HTTP 验收,默认 `--plan` 零网络;显式 `--execute --environment=TEST --run-id=<新批次> --mode=read|workflow --fixture=<本轮清单>` 才执行。运行者用 `node --env-file=<明确TEST配置>` 注入平台、客户及只读平台凭据,变量名和清单合同见 `--plan`。清单必须引用本轮正式业务生产者报告及 SHA256,不借用历史业务样本。read 只登录、查询、退出自身会话;workflow 还写一项跟进并验证原键重放、陈旧版本、异载荷和权限拒绝,可按清单登记客户仓无需预约要求,不代替排车/PDA/签收原域事实。报告写入独占的新 `reports/appointment-work-<run-id>/`;失败保留报告和业务记录,不自动重发未知结果、删除、重置或执行 SQL。离线合同为 `appointment-work-test-env.test.mjs`;只读或离线通过不代表完整运输履约、真实页面通过。
`../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。 `../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。
`db/cargo-discard-consolidated-incremental-20261014.sql`:废弃业务结构增量汇总,MySQL 8.0.16+,仅对 `baiyue_lastleg` 已有业务库执行。覆盖废弃基础表、PDA完工字段、照片关联、退货箱废弃状态、通用库存来源和部分选箱;表/列/索引按存在性判断,约束原子替换,可重复执行。不回填历史订单、不补造出库单、不修改库存数量,无业务 DML;File及仓储资源库无需本次SQL。实例和凭据由执行者指定,完整执行、遇错停止、不使用 `--force`;DDL隐式提交,修正错误后可重跑,末尾 `SHOW CREATE TABLE` 结果由执行者保存核对,不自动清理或连接环境。
`db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。 `db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。 `procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
@@ -506,3 +510,16 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
`f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。 `f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。
- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。 - `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。
废弃明细字段专项修复:db/cargo-discard-item-columns-repair-20261014.sql。仅对 MySQL 8.0.16+ 的 baiyue_lastleg 已有 ll_cargo_discard_item 表补齐 10 个新增字段,逐列判断,可重复执行,独立于退货约束。实例和凭据由执行者指定;不写业务数据,不修改库存,不自动访问环境。完整执行、遇错停止,DDL隐式提交,修正错误后可重跑;末尾只读查询应返回10行,由执行者保存结果,无自动清理。
废弃退货箱归零约束修复:db/cargo-discard-return-quantity-repair-20261014.sql。仅 baiyue_lastleg,MySQL 8.0.16+,将箱内SKU数量约束改为非负以支持整箱废弃。实例及凭据由执行者指定;可重复执行,原子替换约束,无业务DML,不连接环境自动执行。整份执行、遇错停止,DDL隐式提交,修正后可重跑;末尾输出表结构供保存核对,不清理业务数据。正式增量为 deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql,已纳入废弃汇总脚本。
客户废弃照片确认增量:db/cargo-discard-customer-confirmation-incremental-20261014.sql。MySQL 8.0.16+,目标明确为 baiyue_lastleg 和 baiyue_iam;连接实例与凭据由执行者指定。补齐客户确认时间和独立确认权限,默认客户管理员获得授权,自定义角色需手动分配;授权与受影响管理员的安全版本在同一事务提交,重跑安全版本仍单调递增。完整执行、遇错停止、修正后可重跑;不确认或回填历史单据,不修改库存,不自动访问环境或清理。执行结果由操作者保存,权限更新后需重新登录客户账号。对应正式迁移为 Lastleg V27 和 IAM V6;未执行时页面权限与服务字段尚不可用。
`db/warehouse-transfer-carton-counting-incremental.sql`:仓储转运逐箱测量与清点增量,显式 USE baiyue_lastleg;逐列检查并新增箱子尺寸、重量、实点件数、清点操作人、时间、幂等字段、箱所属转运托盘ID及独立SKU申报件数 declared_quantity,可重复执行,无业务DML、不回填历史事实。适用于明确授权的本地或开发MySQL8,凭据由操作者外部提供;失败保留原错误,修复后重跑,不清理单据。已同步尾程基线和海外统一增量。
`local-warehouse-transfer-carton-acceptance.mjs`:仅本机统一Gateway的仓储转运逐箱验收;命令前显式 `node --env-file=<本地配置文件>`,必须提供 ADMIN_TEST_GATEWAY、ADMIN_TEST_USERNAME/PASSWORD、LOCAL_TRANSFER_PDA_PASSWORD,可选 OVERSEAS_TEST_CUSTOMER_ID、TRANSFER_CARTON_OUTPUT、TRANSFER_KEEP_PDA。复用既有有效客户和海外仓,以业务HTTP创建4笔带批次标识的新单,覆盖单箱、多SKU分箱多托、短收及超收、SKU申报件数与预计箱数独立持久化且PDA实收不覆盖原申报、先生成多托盘再逐箱选择托盘、箱级测量清点及托盘体积重量自动汇总、错误SKU/零件数/缺少差异原因拒绝、幂等重放、箱标签、打托绑定、测试照片和入库完成回查。测试照片为合成PNG,不证明真机拍照或物理打印。创建本轮PDA账号,默认finally停用;仅显式 TRANSFER_KEEP_PDA=true 时保留供本轮页面验收后人工停用。不改主档、不写业务数据库、不删除单据;报告不保存凭据或Token,失败保留已创建单号,排查后用新批次重跑,不自动清库或续跑既有单。
`db/warehouse-transfer-consolidated-incremental-20261015.sql`:仓储转运近期本地增量汇总,固定 `USE baiyue_lastleg`,适用已有 Lastleg 基线的本地/开发 MySQL 8。依次合入 `warehouse-transfer-outbound-incremental.sql` 和 `warehouse-transfer-carton-counting-incremental.sql`,前者已覆盖旧 `dev-warehouse-transfer-pallet-lifecycle-incremental.sql`,无须重复跑旧生命周期补丁。包含审核、打托/照片、换标、目的地/出库/派送结构、实收箱数,以及逐箱尺寸、重量、实点件数、清点幂等、差异原因、箱所属托盘和独立 SKU 申报件数。只执行 DDL/元数据查询,无业务 DML;不回填单据或修改库存。实例和凭据由操作者在数据库客户端外部提供,无内置连接信息或报告文件。需支持 DELIMITER 和 CREATE ROUTINE/ALTER/INDEX 权限,整文件串行执行,遇错停止,不使用 --force;可按缺失结构检查重复执行,不修复任意同名异构旧表。末尾 `missing_carton_and_quantity_columns=0`、`inbound_pallet_unique_index=READY`、`migration_result=WAREHOUSE_TRANSFER_RECENT_SCHEMA_READY` 表示本轮列/索引核对通过;同客户重复托盘号会报告 INDEX_BLOCKED,不擅自更改旧托盘号。仅整理文件不表示目标库已经执行;连接必须由操作者明确选中,不重放 `deploy/sql/海外区域统一增量-MySQL8.sql` 空库基线。
+255
View File
@@ -0,0 +1,255 @@
import assert from 'node:assert/strict'
import { mkdir, open, readFile } from 'node:fs/promises'
import { resolve, dirname, relative, isAbsolute } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createHash } from 'node:crypto'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const API = '/api/platform/v1/last-leg', WORK = `${API}/appointment-work`
const id = value => String(value ?? ''), enc = encodeURIComponent
const digest = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex')
const requireFact = (condition, message) => { if (!condition) throw new Error(message) }
const ok = r => r?.http === 200 && id(r.body?.code) === '200'
const refused = (r, status) => (r?.http === status || r?.http === 200) && id(r?.body?.code) === `A0${status}` && r.body?.data == null
const sort = rows => rows.map(row => JSON.stringify(row)).sort()
const source = row => ({ customerId: id(row.customerId), sourceKey: row.sourceKey, destinationKey: row.destinationKey, packageCount: Number(row.packageCount) })
export function plan() {
return { environment: 'TEST', execute: false, entry: 'scripts/appointment-work-test-env.mjs',
modes: { read: 'Only login/logout and GET: selected current-run work, exact FBA allocations/private dispatch pallets, shared trips, explicit retired replacements and actual dates.',
workflow: 'Also writes SAVE_FOLLOWUP on one pending fixture item; verifies reader denial, exact replay, stale version and changed-payload rejection. Optional CUSTOMER SAVE_REQUIREMENT records no booking, without execution.' },
command: 'node --env-file=<private>/test-acceptance.env scripts/appointment-work-test-env.mjs --execute --environment=TEST --run-id=<new> --mode=read|workflow --fixture=reports/<producer>/appointment-work-fixture.json',
prerequisites: ['Latest IAM/Firstleg/Lastleg and permission/DDL baseline deployed by coordinator',
'Current-run formal HTTP producer report, its SHA256 and an explicit fixture; no historical discovery or fabricated inventory',
'CHONGCHONG_ENV=test and CHONGCHONG_ADMIN_API_BASE_URL ending /api; CHONGCHONG_ADMIN_USERNAME/PASSWORD',
'CHONGCHONG_MERCHANT_USERNAME/PASSWORD for customer denial; workflow also APPOINTMENT_WORK_READER_USERNAME/PASSWORD (platform list + source reads, without followup/manage-work)',
'Owner is an active platform IAM user; scheduled synchronization complete before execution'],
fixture: { schemaVersion: 1, environment: 'TEST', runId: '<new>', producerRunId: '<current producer>', producedAt: '<ISO instant>', producerReport: 'reports/<producer>/report.json', producerSha256: '<sha256>',
cases: [{ name: 'customer', containerBookingId: '<id>', type: 'DELIVERY', flow: 'TRANSFER', requirementKey: 'DISPATCH:<id>', batchKey: 'DISPATCH:<id>', batchNo: '<dispatch business number>',
expected: { executionStatus: 'PENDING', planStatus: 'UNPLANNED', dateBasis: 'NONE', calendarDate: null }, containerIds: ['<id>'] }],
batches: [{ case: 'customer', kind: 'CUSTOMER', dispatchNo: '<business number>', packageCount: 1, pallets: [{ planPalletId: '<id>', packageCount: 1 }] }],
sharedTrips: [], retired: [], workflow: { case: 'customer', ownerId: '<active IAM user id>', followupDate: '<YYYY-MM-DD>', noBooking: true } },
fixtureNotes: ['FBA batch entries instead use kind=FBA, appointmentNo, sources:[{customerId,sourceKey,destinationKey,packageCount}], minimumReadyCandidates (0 allowed).',
'sharedTrips:[{case,tripNo,tripId,containerIds,dispatches:[{dispatchNo,customerId,packageCount}]}]. The case must select TRANSPORT.',
'retired:[{workItemKey,containerBookingId,replacementCases:[name]}] checks absence from active container work and exact named replacements in old detail.',
'For COMPLETED cases provide expected.actual and calendarDate/dateBasis=ACTUAL; completed rows are loaded with their date filter.',
'workflow requires one pending item exposing SAVE_FOLLOWUP; noBooking additionally requires editable CUSTOMER DELIVERY and no original appointment plan.'],
minimumProducer: { entry:'scripts/admin-test-env-acceptance.mjs', arguments:'--environment test --modules phase4-carrier-pickup-ready --wait-split-count 1 --wait-split-package-count 1 --output reports/<new-producer>/producer.json',
notes:'Uses its existing ADMIN_TEST_* and ADMIN_PDA_* configuration. Dependency closure creates a new customer/order, measurement/loading/customs/outbound and formal ship-node evidence. Select only the current report createdResources.phase3CarrierScheduleQuery.containerBookingId. First acceptance can target its UNKNOWN ROUTE work with workflow.noBooking=false; this does not prove stable batches.',
batchPrerequisites:'Then use current formal route, cargo-verification/FBA appointment or PRIVATE receiving/pre-arrange APIs to prepare new stable batches; shared trip and completion stages need actual original dispatch/fleet/PDA execution before a new read report.',
existingProducerBlocker:'phase4-overseas-inbound-operations.mjs currently cancels FBA by PUT details.appointmentStatus=CANCELLED; the current service rejects this and requires the dedicated cancellation operation. Do not use that whole module as a verified batch producer until its owner fixes the contract.' },
remaining: ['This package does not create sailed containers, appointments, dispatches, stock or fleet trips, cancel original businesses, pick/depart/sign, or run internal synchronization.',
'Prepare/advance those samples with existing formal producers; run a new read report after each state transition. This is not end-to-end execution proof.',
'PRIVATE per-order pick quantity conservation and DIRECT receiving-write rejection require the original PDA/dispatch producer; this reader checks public batch identity/pallet quantities and completed facts only.',
'1920x1080 manual page evidence remains separate.'],
boundary: 'Default plan is zero network/file writes. Execute claims a new report directory, journals before every write, stops on unknown outcome, retains samples, and logs out only its sessions. No SQL, deployment, deletes or automatic resume.' }
}
export function options(args, execArgs, env) {
if (!args.length || args.length === 1 && args[0] === '--plan') return { execute: false }
const values = new Map()
for (const arg of args) {
const match = /^--(execute|environment|run-id|mode|fixture)(?:=(.*))?$/.exec(arg)
requireFact(match && !values.has(match[1]), 'Unknown or duplicate argument')
values.set(match[1], match[1] === 'execute' && match[2] === undefined ? true : match[2])
}
requireFact(values.size === 5 && values.get('execute') === true && values.get('environment') === 'TEST'
&& /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(values.get('run-id') || '') && ['read', 'workflow'].includes(values.get('mode')) && values.get('fixture'), 'Explicit TEST, new run-id, mode and fixture required')
requireFact(execArgs.length === 1 && execArgs[0].startsWith('--env-file=')
&& resolve(execArgs[0].slice(11)).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env'), 'Explicit authorized TEST env-file required')
requireFact(env.CHONGCHONG_ENV === 'test', 'TEST profile required')
const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
requireFact(['http:', 'https:'].includes(base.protocol) && base.pathname.replace(/\/$/, '') === '/api' && !base.username && !base.password && !base.search && !base.hash, 'Unified TEST API base required')
const credentialKeys = ['CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'CHONGCHONG_MERCHANT_USERNAME', 'CHONGCHONG_MERCHANT_PASSWORD']
if (values.get('mode') === 'workflow') credentialKeys.push('APPOINTMENT_WORK_READER_USERNAME', 'APPOINTMENT_WORK_READER_PASSWORD')
requireFact(credentialKeys.every(key => typeof env[key] === 'string' && env[key]), 'Required actor credentials missing')
return { execute: true, runId: values.get('run-id'), mode: values.get('mode'), fixture: values.get('fixture'), gateway: base.origin, credentialKeys }
}
export function validateFixture(f, config, now = Date.now()) {
requireFact(f?.schemaVersion === 1 && f.environment === 'TEST' && f.runId === config.runId && /^[A-Za-z0-9_-]{1,80}$/.test(f.producerRunId || '')
&& /^[a-f0-9]{64}$/.test(f.producerSha256 || '') && typeof f.producerReport === 'string', 'Current-run HTTP producer provenance required')
const age = now - Date.parse(f.producedAt)
requireFact(Number.isFinite(age) && age >= 0 && age < 24 * 3600000, 'Fixture must have been prepared in the current 24-hour window')
requireFact(Array.isArray(f.cases) && f.cases.length > 0 && f.cases.length <= 30, 'One to thirty explicit work cases required')
const names = new Set()
for (const c of f.cases) {
requireFact(/^[A-Za-z0-9_-]{1,40}$/.test(c.name || '') && !names.has(c.name), 'Unique case names required'); names.add(c.name)
requireFact(typeof c.containerBookingId === 'string' && c.containerBookingId.length > 0 && ['ROUTE','PICKUP','WAREHOUSE_RECEIPT','DELIVERY','TRANSPORT','EMPTY_RETURN'].includes(c.type)
&& ['UNKNOWN','TRANSFER','DIRECT'].includes(c.flow) && Array.isArray(c.containerIds) && c.containerIds.includes(c.containerBookingId), 'Exact case scope required')
requireFact(['PENDING','IN_PROGRESS','COMPLETED'].includes(c.expected?.executionStatus) && ['UNPLANNED','TARGET_SET','AWAITING_REPLY','CONFIRMED','NOT_REQUIRED'].includes(c.expected?.planStatus)
&& ['NONE','TARGET','CONFIRMED','ACTUAL'].includes(c.expected?.dateBasis) && Object.hasOwn(c.expected, 'calendarDate'), 'Explicit status and calendar expectation required')
if (c.expected.executionStatus === 'COMPLETED') requireFact(c.expected.actual && c.expected.dateBasis === 'ACTUAL' && /^\d{4}-\d{2}-\d{2}$/.test(c.expected.calendarDate || ''), 'Completion requires independent actual evidence and date')
}
for (const key of ['batches', 'sharedTrips', 'retired']) requireFact(Array.isArray(f[key]) && f[key].length <= 30, `${key} must be an explicit bounded list`)
for (const b of f.batches) {
const c = f.cases.find(c => c.name === b.case)
requireFact(c?.type === 'DELIVERY' && c.batchKey && ['FBA','CUSTOMER'].includes(b.kind), 'Batch requires its delivery case')
if (b.kind === 'FBA') requireFact(/^FBA_APPOINTMENT:[1-9]\d*$/.test(c.batchKey) && b.appointmentNo && Array.isArray(b.sources) && b.sources.length > 0 && b.sources.every(s => s.customerId && s.sourceKey && s.destinationKey && Number.isSafeInteger(s.packageCount) && s.packageCount > 0) && Number.isSafeInteger(b.minimumReadyCandidates) && b.minimumReadyCandidates >= 0, 'Exact FBA source expectations required')
else requireFact(/^DISPATCH:[1-9]\d*$/.test(c.batchKey) && b.dispatchNo && Number.isSafeInteger(b.packageCount) && b.packageCount > 0 && Array.isArray(b.pallets) && b.pallets.length > 0 && b.pallets.every(p => typeof p.planPalletId === 'string' && Number.isSafeInteger(p.packageCount) && p.packageCount > 0), 'Exact private dispatch physical expectations required')
}
for (const t of f.sharedTrips) requireFact(f.cases.some(c => c.name === t.case && c.type === 'TRANSPORT') && t.tripNo && typeof t.tripId === 'string' && Array.isArray(t.containerIds) && t.containerIds.length > 0 && Array.isArray(t.dispatches) && t.dispatches.length > 0, 'Exact trip source expectations required')
for (const r of f.retired) requireFact(r.workItemKey && r.containerBookingId && r.replacementCases?.length > 0 && r.replacementCases.every(n => names.has(n)), 'Explicit retired replacements required')
if (config.mode === 'workflow') requireFact(names.has(f.workflow?.case) && typeof f.workflow.ownerId === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(f.workflow.followupDate || '') && typeof f.workflow.noBooking === 'boolean', 'Workflow target, owner and follow-up required')
return f
}
export function validatePage(page, number, size = 100) {
requireFact(page && Number.isSafeInteger(page.total) && page.total >= 0 && page.page === number && page.size === size && page.pages === Math.ceil(page.total / size)
&& Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, page.total - (number - 1) * size)), 'Incomplete work page')
return page.records
}
export function assertItem(item, c) {
requireFact(item?.workItemKey && item.type === c.type && item.flow === c.flow, `Case ${c.name}: wrong work identity`)
for (const key of ['requirementKey','batchKey','batchNo']) if (Object.hasOwn(c, key)) assert.equal(item[key], c[key], `Case ${c.name}: ${key}`)
assert.deepEqual(item.containers.map(r => id(r.containerBookingId)).sort(), [...c.containerIds].sort(), `Case ${c.name}: current container scope`)
for (const [key, value] of Object.entries(c.expected)) { requireFact(['executionStatus','planStatus','dateBasis','calendarDate','actual','reviewRequired','timezone'].includes(key), 'Unsupported expectation field'); assert.deepEqual(item[key], value, `Case ${c.name}: ${key}`) }
if (item.executionStatus !== 'COMPLETED') assert.equal(item.actual, null, 'Unfinished work must not invent completion')
if (c.flow === 'DIRECT') requireFact(item.type !== 'WAREHOUSE_RECEIPT', 'DIRECT cannot create warehouse receipt work')
return item
}
/** Runs only on explicit current-run identities. Caller provides authenticated, journaled transport. */
export async function verify(context, fixture, config) {
const checks = [], resolved = new Map()
const check = (name, condition) => { requireFact(condition, name); checks.push(name) }
async function data(method, path, body, actor = 'admin') {
const r = await context.request(actor, method, path, body)
requireFact(ok(r), `HTTP_FAILURE ${method} ${path.split('?')[0]} http=${r?.http} code=${id(r?.body?.code)} requestId=${id(r?.requestId)} traceId=${id(r?.traceId)}`)
return r.body.data
}
async function rows(container, date) {
const result = []; let total = null
for (let page = 1; page <= 100; page++) {
const query = new URLSearchParams({ view: 'ALL', page: id(page), size: '100', containerBookingId: container, ...(date ? { date } : {}) })
const response = await data('GET', `${WORK}/items?${query}`), next = validatePage(response, page)
requireFact(total == null || total === response.total, 'Work scope changed during pagination'); total = response.total
result.push(...next); if (page >= response.pages) { requireFact(result.length === total && new Set(result.map(i => i.workItemKey)).size === total, 'Duplicate or missing work rows'); return result }
}
throw Error('Work pagination limit exceeded')
}
const anonymous = await context.request('anonymous', 'GET', `${WORK}/summary`)
check('anonymous cannot read work', [401,403].includes(anonymous?.http) && anonymous.body?.data == null)
check('customer cannot read platform work', refused(await context.request('merchant', 'GET', `${WORK}/summary`), 403))
const summary = await data('GET', `${WORK}/summary`)
check('summary contains complete initialized counters', ['all','followToday','overdue','undated','review'].every(k=>Number.isSafeInteger(summary[k])&&summary[k]>=0))
for (const c of fixture.cases) {
const current = await rows(c.containerBookingId, c.expected.executionStatus === 'COMPLETED' ? c.expected.calendarDate : null)
if (c.flow === 'DIRECT') check(`${c.name}: no active warehouse receiving branch`, !current.some(i => i.type === 'WAREHOUSE_RECEIPT'))
const matches = current.filter(i => i.type === c.type && i.flow === c.flow && ['requirementKey','batchKey','batchNo'].every(k => !Object.hasOwn(c,k) || i[k] === c[k]))
check(`${c.name}: exactly one current item`, matches.length === 1); assertItem(matches[0], c)
const detail = await data('GET', `${WORK}/items/${enc(matches[0].workItemKey)}`); assertItem(detail.item, c)
const second = await data('GET', `${WORK}/items/${enc(matches[0].workItemKey)}`)
assert.deepEqual(second, detail, 'Repeated GET must not write history or versions')
resolved.set(c.name, detail); checks.push(`${c.name}: detail/get-no-write/status/date verified`)
if (c.expected.calendarDate) {
const day = c.expected.calendarDate, calendar = await data('GET', `${WORK}/calendar?from=${enc(day)}&to=${enc(day)}`)
check(`${c.name}: calendar uses authoritative date`, calendar.days.some(d => d.date === day && d.total > 0 && d.typeCounts[c.type] > 0))
}
}
for (const b of fixture.batches) {
const item = resolved.get(b.case).item
if (b.kind === 'FBA') {
const batch = await data('GET', `${API}/appointment-planning/batches/${enc(item.batchKey)}`)
check(`${b.case}: immutable FBA batch and business number`, batch.appointment.batchKey === item.batchKey && batch.appointment.appointmentNo === b.appointmentNo && item.batchNo === b.appointmentNo)
assert.deepEqual(sort(batch.appointment.items.map(source)), sort(b.sources.map(source)), 'FBA per-source quantities must match producer evidence')
const candidates = await data('GET', `${API}/appointment-planning/batches/${enc(item.batchKey)}/dispatch-candidates`)
check(`${b.case}: complete FBA candidates`, candidates.batchKey === item.batchKey && candidates.version === batch.appointment.version && Array.isArray(candidates.candidates) && Array.isArray(candidates.blockReasons))
const ready = candidates.candidates.filter(c => c.blockReasons.length === 0)
check(`${b.case}: required real physical candidates`, ready.length >= b.minimumReadyCandidates)
for (const candidate of ready) check(`${b.case}: candidate per-source quantity conservation`, candidate.allocations.length > 0 && candidate.allocations.every(a => a.batchKey === item.batchKey && id(a.customerId) === id(candidate.customerId) && id(a.planPalletId) === id(candidate.planPalletId)) && candidate.allocations.reduce((n,a) => n+a.packageCount,0) === candidate.packageCount)
} else {
const dispatch = await data('GET', `${API}/dispatches/${enc(b.dispatchNo)}`)
check(`${b.case}: original private dispatch identity`, item.batchKey === `DISPATCH:${dispatch.id}` && item.batchNo === dispatch.dispatchNo && dispatch.dispatchNo === b.dispatchNo)
check(`${b.case}: physical total`, dispatch.packageCount === b.packageCount && dispatch.items.reduce((n,p) => n+p.packageCount,0) === b.packageCount)
assert.deepEqual(sort(dispatch.items.map(p => ({planPalletId:id(p.planPalletId),packageCount:p.packageCount}))), sort(b.pallets), 'Private original pallet allocation drift')
}
}
for (const t of fixture.sharedTrips) {
const detail = resolved.get(t.case), trip = await data('GET', `${API}/fleet/trips/${enc(t.tripNo)}`)
check(`${t.case}: canonical trip identity`, detail.item.businessNo === t.tripNo && detail.references.some(r => r.kind === 'FLEET_TRIP' && r.key === t.tripId && r.businessNo === t.tripNo) && trip.trip.tripNo === t.tripNo)
assert.deepEqual(detail.item.containers.map(c => c.containerBookingId).sort(), [...t.containerIds].sort(), 'Shared trip container membership drift')
assert.deepEqual(sort(trip.dispatches.map(d => ({dispatchNo:d.dispatchNo,customerId:id(d.customerId),packageCount:d.packageCount}))), sort(t.dispatches), 'Shared trip original dispatch membership drift')
for (const container of t.containerIds) check(`${t.case}: shared transport counted once per container`, (await rows(container, detail.item.calendarDate || undefined)).filter(i => i.type === 'TRANSPORT' && i.workItemKey === detail.item.workItemKey).length === 1)
}
for (const r of fixture.retired) {
check('retired item excluded from active scope', !(await rows(r.containerBookingId)).some(i => i.workItemKey === r.workItemKey))
const old = await data('GET', `${WORK}/items/${enc(r.workItemKey)}`)
check('retired item retains history and read-only actions', old.history.some(h => h.action === 'REPLACED') && old.item.allowedActions.every(a => ['VIEW_HISTORY','VIEW_DETAIL'].includes(a.code)))
for (const name of r.replacementCases) check('original requirement restored or explicitly replaced', old.relatedItems.some(i => i.workItemKey === resolved.get(name).item.workItemKey))
}
if (config.mode === 'workflow') {
const w = fixture.workflow, before = resolved.get(w.case), key = before.item.workItemKey, path = `${WORK}/items/${enc(key)}`
check('workflow sample is unfinished and exposes followup', before.item.executionStatus !== 'COMPLETED' && before.item.allowedActions.some(a => a.code === 'SAVE_FOLLOWUP'))
await data('GET', path, undefined, 'reader')
const command = { command:'SAVE_FOLLOWUP', expectedVersion:before.item.version, idempotencyKey:`AW-${config.runId}-FOLLOWUP`, payload:{ownerId:w.ownerId,followupDate:w.followupDate,note:`预约工作HTTP验收 ${config.runId}`} }
check('list-only platform cannot write followup', refused(await context.request('reader','POST',`${path}/commands`,command),403))
assert.deepEqual(await data('GET',path),before,'Denied write changed original work')
const result = await data('POST',`${path}/commands`,command), saved = await data('GET',path)
check('followup resolves the active IAM owner', result.item.owner?.id === w.ownerId && typeof result.item.owner.name === 'string' && result.item.owner.name.length > 0 && result.item.followupDate === w.followupDate)
assert.deepEqual(result.item.actual,before.item.actual,'Followup invented execution'); assert.equal(result.item.executionStatus,before.item.executionStatus)
check('one command creates one history entry',saved.history.length===before.history.length+1)
assert.deepEqual(await data('POST',`${path}/commands`,command),result,'Idempotent replay changed response')
assert.deepEqual(await data('GET',path),saved,'Idempotent replay wrote history or version')
check('changed payload under same key rejected',refused(await context.request('admin','POST',`${path}/commands`,{...command,payload:{...command.payload,note:'变更内容应拒绝'}}),409))
check('stale version rejected',refused(await context.request('admin','POST',`${path}/commands`,{...command,idempotencyKey:`AW-${config.runId}-STALE`}),409))
assert.deepEqual(await data('GET',path),saved,'Rejected stale/tampered write changed work')
if(w.noBooking) {
check('explicit customer requirement is editable',before.item.type==='DELIVERY' && saved.requirement && saved.item.allowedActions.some(a=>a.code==='SAVE_REQUIREMENT'))
const updated=await data('POST',`${path}/commands`,{command:'SAVE_REQUIREMENT',expectedVersion:saved.item.version,idempotencyKey:`AW-${config.runId}-NOBOOK`,payload:{target:null,bookingRequired:false,receivingRequirements:'本轮测试客户仓按到货接收,无需外部预约',reason:`明确收货要求 ${config.runId}`}})
check('no booking does not mean executed',updated.item.planStatus==='NOT_REQUIRED' && updated.item.executionStatus===before.item.executionStatus && updated.item.actual===null)
}
}
return { checks, resolved: Object.fromEntries([...resolved].map(([name,d])=>[name,{workItemKey:d.item.workItemKey,version:d.item.version,batchKey:d.item.batchKey,batchNo:d.item.batchNo,executionStatus:d.item.executionStatus}])) }
}
function inReports(path) {
const absolute=resolve(ROOT,path), rel=relative(resolve(ROOT,'reports'),absolute)
requireFact(rel && !rel.startsWith('..') && !isAbsolute(rel), 'Fixture and producer evidence must stay inside this TEST workspace reports directory')
return absolute
}
export async function execute(args=process.argv.slice(2),execArgs=process.execArgv,env=process.env) {
const config=options(args,execArgs,env);if(!config.execute)return plan()
requireFact(ROOT.replaceAll('\\','/').endsWith('/chongchong-global/baiyue-firstleg'),'Registered TEST workspace required')
const fixtureBytes=await readFile(inReports(config.fixture));requireFact(fixtureBytes.length<=1024*1024,'Fixture too large')
const fixture=validateFixture(JSON.parse(fixtureBytes),config)
const producer=await readFile(inReports(fixture.producerReport));requireFact(digest(producer)===fixture.producerSha256,'Producer evidence digest mismatch')
const directory=resolve(ROOT,'reports',`appointment-work-${config.runId}`)
await mkdir(dirname(directory),{recursive:true});await mkdir(directory)
let sequence=0
const report={runId:config.runId,producerRunId:fixture.producerRunId,fixtureSha256:digest(fixtureBytes),mode:config.mode,status:'RUNNING',startedAt:new Date().toISOString(),deploymentBlocker:false,checks:[],sessionsClosed:[],remaining:plan().remaining}
const secrets=config.credentialKeys.map(k=>env[k]),sessions=new Map()
async function save(name,value) {const file=await open(resolve(directory,name),'wx',0o600);try{await file.writeFile(JSON.stringify(redactFleetWrite(value,secrets),null,2)+'\n');await file.sync()}finally{await file.close()}}
async function checkpoint(entry) {
// Public query responses can include unrelated records; persist only own request and diagnostic metadata.
const {responseBody,raw,...evidence}=entry
if(evidence.path?.startsWith('/api/auth/')) evidence.body={sessionOperation:true}
await save(`${String(++sequence).padStart(5,'0')}-${entry.status||'event'}.json`,{...evidence,responseCode:responseBody?.code})
}
await save('claim.json',report)
const transport=new AcceptanceRun({environment:'test',gateway:config.gateway,username:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD,quiet:true,modules:[]})
const cleanupRequest=transport.request.bind(transport)
guardFleetWrites(transport,checkpoint)
const request=async(actor,method,path,body)=> {
requireFact(method==='GET' || method==='POST' && (['/api/auth/login','/api/auth/logout'].includes(path) || /^\/api\/platform\/v1\/last-leg\/appointment-work\/items\/[^/]+\/commands$/.test(path)),'Unexpected write boundary')
const response=await transport.request(method,path,body,sessions.get(actor)?.accessToken||'')
if(method==='GET')await checkpoint({status:'READ',actor,method,path,http:response.http,code:response.body?.code,requestId:response.requestId,traceId:response.traceId})
return response
}
try {
const actors=[['admin','CHONGCHONG_ADMIN'],['merchant','CHONGCHONG_MERCHANT'],...(config.mode==='workflow'?[['reader','APPOINTMENT_WORK_READER']]:[])]
for(const[name,prefix]of actors){const r=await request(name,'POST','/api/auth/login',{loginId:env[`${prefix}_USERNAME`],password:env[`${prefix}_PASSWORD`]});requireFact(ok(r)&&r.body.data?.accessToken&&r.body.data?.refreshToken,'Incomplete login');sessions.set(name,r.body.data);secrets.push(r.body.data.accessToken,r.body.data.refreshToken)}
Object.assign(report,await verify({request},fixture,config));report.status='VERIFIED'
}catch(error){report.status=String(error.message).includes('Unknown write outcome')?'UNKNOWN_STOP':'FAILED';report.deploymentBlocker=true;report.failure=redactFleetWrite(String(error.message),secrets)}
finally {
for(const[name,session]of sessions){
try {await checkpoint({status:'LOGOUT_SENDING',actor:name,path:'/api/auth/logout'});const r=await cleanupRequest('POST','/api/auth/logout',{refreshToken:session.refreshToken},session.accessToken);await checkpoint({status:'LOGOUT_RESULT',actor:name,http:r.http,code:r.body?.code,requestId:r.requestId,traceId:r.traceId});requireFact(ok(r),'Logout failed');report.sessionsClosed.push(name)}
catch{report.deploymentBlocker=true;report.logoutFailure=true;if(report.status==='VERIFIED')report.status='FAILED'}
}
report.completedAt=new Date().toISOString();await save('report.json',report)
}
return {success:report.status==='VERIFIED',status:report.status,checks:report.checks.length,report:resolve(directory,'report.json')}
}
if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){execute().then(result=>{console.log(JSON.stringify(result,null,2));if(result.success===false)process.exitCode=1}).catch(()=>{console.error('Appointment work acceptance not started: verify explicit TEST configuration, current-run fixture and unused report directory.');process.exitCode=1})}
+143
View File
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { spawnSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { options, plan, validateFixture, validatePage, assertItem, verify, execute } from './appointment-work-test-env.mjs'
import { guardFleetWrites } from './fleet-test-env-acceptance.mjs'
const WORK='/api/platform/v1/last-leg/appointment-work', API='/api/platform/v1/last-leg'
const now=Date.parse('2026-10-15T08:00:00Z')
function sample() {
return {schemaVersion:1,environment:'TEST',runId:'aw-test',producerRunId:'producer-test',producedAt:'2026-10-15T07:00:00Z',producerReport:'reports/producer/report.json',producerSha256:'a'.repeat(64),
cases:[{name:'private',containerBookingId:'1',containerIds:['1'],type:'DELIVERY',flow:'TRANSFER',requirementKey:'DISPATCH:20',batchKey:'DISPATCH:20',batchNo:'D-20',expected:{executionStatus:'PENDING',planStatus:'UNPLANNED',dateBasis:'NONE',calendarDate:null}}],
batches:[{case:'private',kind:'CUSTOMER',dispatchNo:'D-20',packageCount:4,pallets:[{planPalletId:'10',packageCount:4}]}],sharedTrips:[],retired:[],workflow:{case:'private',ownerId:'90',followupDate:'2026-10-16',noBooking:true}}
}
function workItem(c=sample().cases[0]) {
return {workItemKey:'WORK-1',version:1,type:c.type,flow:c.flow,requirementKey:c.requirementKey,batchKey:c.batchKey,batchNo:c.batchNo,businessNo:'D-20',
containers:c.containerIds.map(id=>({containerBookingId:id,containerNo:`CNT-${id}`,bookingNo:null})),...c.expected,actual:c.expected.actual||null,
allowedActions:[{code:'SAVE_FOLLOWUP'},{code:'SAVE_REQUIREMENT'}],blockReasons:[],reviewRequired:false,owner:null,followupDate:null}
}
function response(data,http=200,code='200') {return {http,body:{code,data},requestId:'REQ',traceId:'TRACE'}}
function harness(fixture=sample()) {
const requests=[], detail={item:workItem(fixture.cases[0]),history:[],references:[],relatedItems:[],requirement:{bookingRequired:true},review:null,returnHandoff:null},replays=new Map()
const clone=v=>structuredClone(v)
async function request(actor,method,path,body) {
requests.push({actor,method,path,body:clone(body)})
if(actor==='anonymous')return response(null,401,'A0401')
if(actor==='merchant'||actor==='reader'&&method==='POST')return response(null,403,'A0403')
if(path===`${WORK}/summary`)return response({all:1,followToday:0,overdue:0,undated:1,review:0})
if(path.startsWith(`${WORK}/items?`))return response({records:[clone(detail.item)],page:1,size:100,total:1,pages:1})
if(path===`${WORK}/items/WORK-1`&&method==='GET')return response(clone(detail))
if(path===`${API}/dispatches/D-20`)return response({id:'20',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:4}]})
if(path===`${WORK}/items/WORK-1/commands`) {
if(replays.has(body.idempotencyKey)){const old=replays.get(body.idempotencyKey);return JSON.stringify(old.body)===JSON.stringify(body)?response(clone(old.result)):response(null,409,'A0409')}
if(body.expectedVersion!==detail.item.version)return response(null,409,'A0409')
if(body.command==='SAVE_FOLLOWUP'){detail.item.owner={id:body.payload.ownerId,name:'验收操作员'};detail.item.followupDate=body.payload.followupDate}
else if(body.command==='SAVE_REQUIREMENT'){detail.item.planStatus='NOT_REQUIRED';detail.requirement.bookingRequired=false}
else throw Error('Unexpected command')
detail.item.version++;detail.history.push({action:body.command,summary:'fixture'})
const result={item:clone(detail.item),affectedWorkItemKeys:[]};replays.set(body.idempotencyKey,{body:clone(body),result:clone(result)});return response(result)
}
throw Error(`Unexpected request ${method} ${path}`)
}
return {request,requests,detail}
}
test('default and explicit plan perform no network and no fixture/file reads',async()=>{
const original=globalThis.fetch;globalThis.fetch=()=>{throw Error('NETWORK_FORBIDDEN')}
try {assert.equal((await execute([],[],{})).execute,false);assert.deepEqual(await execute(['--plan'],[],{}),plan())}finally{globalThis.fetch=original}
const child=spawnSync(process.execPath,['scripts/appointment-work-test-env.mjs','--plan'],{encoding:'utf8'})
assert.equal(child.status,0,child.stderr);assert.equal(JSON.parse(child.stdout).execute,false)
})
test('execution rejects ambiguous environments, duplicate args and wrong private profile',()=>{
const args=['--execute','--environment=TEST','--run-id=aw-test','--mode=workflow','--fixture=reports/new/fixture.json']
const env={CHONGCHONG_ENV:'test',CHONGCHONG_ADMIN_API_BASE_URL:'https://example.invalid/api',CHONGCHONG_ADMIN_USERNAME:'admin',CHONGCHONG_ADMIN_PASSWORD:'secret',CHONGCHONG_MERCHANT_USERNAME:'customer',CHONGCHONG_MERCHANT_PASSWORD:'secret',APPOINTMENT_WORK_READER_USERNAME:'reader',APPOINTMENT_WORK_READER_PASSWORD:'secret'}
const flags=['--env-file=C:/Users/test/.codex/env/chongchong-global/test-acceptance.env']
assert.equal(options(args,flags,env).mode,'workflow')
assert.throws(()=>options([...args,'--mode=read'],flags,env))
assert.throws(()=>options(args,flags,{...env,CHONGCHONG_ENV:'prod'}))
assert.throws(()=>options(args,['--env-file=other.env'],env))
assert.throws(()=>options(args,flags,{...env,CHONGCHONG_ADMIN_API_BASE_URL:'https://example.invalid/overseas-api'}))
assert.throws(()=>options(args,flags,{...env,APPOINTMENT_WORK_READER_PASSWORD:''}))
})
test('fixture requires current producer evidence and explicit nonempty expectations',()=>{
assert.equal(validateFixture(sample(),{runId:'aw-test',mode:'workflow'},now).cases.length,1)
for(const patch of [{cases:[]},{runId:'old'},{producedAt:'2026-10-13T00:00:00Z'},{producerSha256:''},{batches:[{case:'private',kind:'CUSTOMER',dispatchNo:'D-20',packageCount:4,pallets:[]}]}])assert.throws(()=>validateFixture({...sample(),...patch},{runId:'aw-test',mode:'workflow'},now))
const incomplete=sample();incomplete.cases[0].expected.executionStatus='COMPLETED';assert.throws(()=>validateFixture(incomplete,{runId:'aw-test',mode:'read'},now))
})
test('pagination and actual evidence cannot silently truncate or invent completion',()=>{
assert.throws(()=>validatePage({records:[],page:1,size:100,total:1,pages:1},1))
const c=sample().cases[0], item=workItem(c);assertItem(item,c)
assert.throws(()=>assertItem({...item,actual:{localOccurredAt:'2026-10-15T11:00:00',occurredAt:null,timezone:null}},c))
assert.throws(()=>assertItem({...item,containers:[{containerBookingId:'foreign'}]},c))
})
test('workflow runs denial/replay/stale/no-booking checks without original-business writes',async()=>{
const h=harness(),result=await verify(h,sample(),{mode:'workflow',runId:'aw-test'})
assert.ok(result.checks.includes('no booking does not mean executed'))
assert.ok(result.checks.includes('stale version rejected'))
assert.equal(h.detail.item.executionStatus,'PENDING');assert.equal(h.detail.item.actual,null)
assert.equal(h.detail.history.length,2)
assert.ok(h.requests.filter(r=>r.method!=='GET').every(r=>r.method==='POST'&&r.path===`${WORK}/items/WORK-1/commands`))
assert.ok(!h.requests.some(r=>r.path.includes('/DISPATCH%3A20/dispatch-candidates')))
})
test('read mode is GET-only and rejects private dispatch identity or quantity drift',async()=>{
const h=harness();await verify(h,sample(),{mode:'read',runId:'aw-test'});assert.ok(h.requests.every(r=>r.method==='GET'))
for(const wrong of [{id:'21',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:4}]},{id:'20',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:3}]}]){
const h=harness();await assert.rejects(()=>verify({request:async(...args)=>args[2]===`${API}/dispatches/D-20`?response(wrong):h.request(...args)},sample(),{mode:'read',runId:'aw-test'}))
}
})
test('denied writes must leave history and versions unchanged',async()=>{
const h=harness();await assert.rejects(()=>verify({request:async(...args)=>{const r=await h.request(...args);if(args[0]==='reader'&&args[1]==='POST')h.detail.item.version++;return r}},sample(),{mode:'workflow',runId:'aw-test'}),/Denied write/)
})
test('FBA batch source allocation and candidate quantity are independently checked',async()=>{
const f=sample(),c=f.cases[0];Object.assign(c,{name:'fba',batchKey:'FBA_APPOINTMENT:20',requirementKey:'FBA_APPOINTMENT:20',batchNo:'AP-20'})
const expected={customerId:'C1',sourceKey:'ORDER:1',destinationKey:'FBA:ONT8',packageCount:4}
f.batches=[{case:'fba',kind:'FBA',appointmentNo:'AP-20',sources:[expected],minimumReadyCandidates:1}]
const h=harness(f)
let count=4
const request=async(...args)=>{
if(args[2]===`${API}/appointment-planning/batches/FBA_APPOINTMENT%3A20`)return response({appointment:{batchKey:c.batchKey,appointmentNo:'AP-20',version:1,items:[expected]}})
if(args[2].endsWith('/dispatch-candidates'))return response({batchKey:c.batchKey,version:1,blockReasons:[],candidates:[{planPalletId:'10',customerId:'C1',packageCount:4,blockReasons:[],allocations:[{...expected,packageCount:count,batchKey:c.batchKey,planPalletId:'10'}]}]})
return h.request(...args)
}
await verify({request},f,{mode:'read',runId:'aw-test'});count=3;await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/quantity conservation/)
})
test('shared trip checks original dispatch identities, quantities and current containers',async()=>{
const f=sample(),c=f.cases[0];Object.assign(c,{name:'trip',type:'TRANSPORT',containerIds:['1','2'],batchKey:null,batchNo:null,requirementKey:null});f.batches=[]
f.sharedTrips=[{case:'trip',tripNo:'TR-1',tripId:'100',containerIds:['1','2'],dispatches:[{dispatchNo:'D-20',customerId:'C1',packageCount:4}]}]
const h=harness(f);h.detail.item.businessNo='TR-1';h.detail.references=[{kind:'FLEET_TRIP',key:'100',businessNo:'TR-1'}]
const request=async(...args)=>args[2]===`${API}/fleet/trips/TR-1`?response({trip:{tripNo:'TR-1'},dispatches:f.sharedTrips[0].dispatches}):h.request(...args)
await verify({request},f,{mode:'read',runId:'aw-test'})
h.detail.references[0].key='999';await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/canonical trip identity/)
})
test('completed local facts keep their explicit date and do not disappear behind the pending filter',async()=>{
const f=sample();f.batches=[];Object.assign(f.cases[0].expected,{executionStatus:'COMPLETED',dateBasis:'ACTUAL',calendarDate:'2026-10-15',actual:{occurredAt:null,localOccurredAt:'2026-10-15T23:30:00',timezone:null}})
const h=harness(f),request=async(...args)=>args[2].startsWith(`${WORK}/calendar?`)?response({days:[{date:'2026-10-15',total:1,typeCounts:{DELIVERY:1}}]}):h.request(...args)
await verify({request},f,{mode:'read',runId:'aw-test'})
assert.ok(h.requests.some(r=>r.path.includes('/items?')&&r.path.includes('date=2026-10-15')))
assert.equal(h.requests.filter(r=>r.method==='POST').length,0)
})
test('retired cancellation requires visible original history and exact replacement identities',async()=>{
const f=sample();f.retired=[{workItemKey:'OLD',containerBookingId:'1',replacementCases:['private']}]
const h=harness();let replacements=[structuredClone(h.detail.item)]
const request=async(...args)=>args[2]===`${WORK}/items/OLD`?response({history:[{action:'REPLACED'}],item:{allowedActions:[{code:'VIEW_HISTORY'}]},relatedItems:replacements}):h.request(...args)
await verify({request},f,{mode:'read',runId:'aw-test'});replacements=[]
await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/original requirement restored/)
})
test('existing write guard persists before send and halts after unknown result',async()=>{
const events=[],context={options:{},request:async()=>{events.push('SENT');return {http:503,body:null}}}
guardFleetWrites(context,async entry=>events.push(entry.status))
await assert.rejects(()=>context.request('POST',`${WORK}/items/WORK-1/commands`,{}),/Unknown write outcome/)
await assert.rejects(()=>context.request('POST',`${WORK}/items/WORK-1/commands`,{}))
assert.deepEqual(events,['SENDING','SENT','UNKNOWN_RESULT'])
let sends=0;const diskFailure={options:{},request:async()=>{sends++}}
guardFleetWrites(diskFailure,async()=>{throw Error('DISK_FAILED')})
await assert.rejects(()=>diskFailure.request('POST','/api/auth/login',{}),/DISK_FAILED/);assert.equal(sends,0)
})
test('entry keeps report exclusive and reuses formal transport without SQL/internal sync/deploy',async()=>{
const code=await readFile(new URL('./appointment-work-test-env.mjs',import.meta.url),'utf8')
assert.match(code,/new AcceptanceRun/);assert.match(code,/guardFleetWrites\(transport,checkpoint\)/)
assert.match(code,/await mkdir\(directory\)/);assert.match(code,/'wx',0o600/);assert.match(code,/await file\.sync\(\)/)
assert.match(code,/digest\(producer\)===fixture\.producerSha256/)
assert.doesNotMatch(code,/mysql|JENKINS_|\/internal\/|fetch\(/)
})
@@ -0,0 +1,190 @@
-- 货物废弃增量汇总(2026-10-14),MySQL 8.0.16+,可重复执行。
-- 目标仅 baiyue_lastleg,连接实例和凭据由执行者指定。
-- 前提:已有尾程入仓箱、退货库存及出库基线;不是空库初始化脚本。
-- 覆盖基础废弃表、PDA完工、退货箱废弃、照片、通用库存及部分选箱。
-- 不回填历史单据、不补造出库单、不修改库存数量、不执行业务DML。
-- File复用已有文件表,无需修改 baiyue_file 或仓储资源库。
-- 完整执行,遇错停止,不使用 --force;DDL隐式提交,失败修正后可重跑。
-- 已有同名表/列/索引沿用,不修复任意未知旧结构,末尾输出结构供核对。
USE `baiyue_lastleg`;
SET NAMES utf8mb4;
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port, VERSION() AS mysql_version;
-- 一、废弃业务表,存在则保留。
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认完成时间',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合',
total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用',
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
reviewed_at DATETIME(3) NULL COMMENT '审核时间', 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_ll_cargo_discard_no(customer_id,discard_no),
UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key),
KEY idx_ll_cargo_discard_list(customer_id,status,created_at),
KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='货物废弃申请单';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID',
sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id)
) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='货物废弃审批历史';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 二、补齐已有表的PDA字段及完工照片快照。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'),
'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认完成时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='completion_photo_file_ids'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 三、退货箱来源和废弃状态约束(V4)。
ALTER TABLE ll_cargo_discard_item
MODIFY COLUMN order_id BIGINT NULL COMMENT '一件代发订单ID',
MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID',
MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID';
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'),
'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))',
'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'),
'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status, ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))',
'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'),
'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 四、通用库存、部分选箱、箱来源(V25)。
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 五、退货箱SKU归零约束(V26)。
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
-- 原子替换约束,可重复执行;不修改业务记录。
SET @ddl = IF(EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
),
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 六、只读核对。
SHOW CREATE TABLE `ll_cargo_discard_application`;
SHOW CREATE TABLE `ll_cargo_discard_item`;
SHOW CREATE TABLE `ll_cargo_discard_carton`;
SHOW CREATE TABLE `ll_cargo_discard_status_history`;
SHOW CREATE TABLE `ll_cargo_discard_photo`;
SHOW CREATE TABLE `ll_return_inventory_carton`;
SHOW CREATE TABLE `ll_inbound_plan_carton`;
SHOW CREATE TABLE ll_return_inventory_carton_item;
@@ -0,0 +1,30 @@
-- 客户照片确认功能增量,MySQL 8.0.16+,可重复执行。
-- 完整执行,遇错停止;须连接同时承载这两个业务库的指定实例。
-- 仅新增确认时间和权限,不确认/回填任何历史业务单据。
USE `baiyue_lastleg`;
-- 客户确认废弃完结时间,可重复执行;仅结构变更。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
USE `baiyue_iam`;
-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。
-- 自定义客户角色由管理员按需分配;不自动扩大其权限。
-- 可重复执行;授权和持久化安全版本在同一事务提交。
START TRANSACTION;
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0;
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
JOIN iam_role r ON r.id=mr.role_id
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT'
AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
COMMIT;
@@ -0,0 +1,53 @@
-- 废弃明细新增字段专项补齐,MySQL 8.0.16+,可重复执行。
-- 仅 baiyue_lastleg;前提是 ll_cargo_discard_item 已存在。
-- 独立于退货库存约束,无需先执行其他汇总章节。
-- 不修改业务记录或库存数量;完整执行,遇错停止,DDL隐式提交。
USE `baiyue_lastleg`;
SET NAMES utf8mb4;
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='completion_photo_file_ids'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 核对新增字段;应返回10行。
SELECT column_name, column_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item'
AND column_name IN ('actual_discard_packages','pda_status','pda_operator_id','pda_completed_at','completion_photo_file_ids','source_type','return_carton_id','inventory_position_id','carton_marks_json','carton_scan_required')
ORDER BY ordinal_position;
@@ -0,0 +1,20 @@
-- MySQL 8.0.16+;对Lastleg连接的实例完整执行,遇错停止。DDL隐式提交。
USE `baiyue_lastleg`;
SET NAMES utf8mb4;
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port;
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
-- 原子替换约束,可重复执行;不修改业务记录。
SET @ddl = IF(EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
),
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SHOW CREATE TABLE ll_return_inventory_carton_item;
+31 -1
View File
@@ -1388,9 +1388,13 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id), KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; ) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
@@ -1398,6 +1402,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
@@ -4134,7 +4139,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt; PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item ( CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量', id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0) PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量'; ) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance ( CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
@@ -5079,3 +5084,28 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 -- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,30 @@
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
@@ -0,0 +1,118 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { randomInt, randomUUID } from 'node:crypto'
const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '')
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('Explicit localhost Gateway required')
if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD) throw new Error('Explicit local platform credentials required')
const marker = `WT-BOX-${new Date().toISOString().replace(/\D/g, '').slice(0,14)}-${randomInt(1000,9999)}`
const output = resolve(process.env.TRANSFER_CARTON_OUTPUT || `reports/${marker}.json`)
const report = { marker, gateway, environment: 'LOCAL', photoEvidence: 'SYNTHETIC_TEST_PNG', passed: false, orders: [], checks: [] }
let admin, pda, userId
const ok = value => value.http === 200 && String(value.body?.code) === '200'
async function call(token, method, path, body, expectedFailure = false) {
const isForm = body instanceof FormData
const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(!isForm && body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : isForm ? body : JSON.stringify(body) })
let value; try { value = await response.json() } catch { value = null }
const result = { http: response.status, body: value, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId }
// Never persist auth bodies, credentials, customer profiles or tokens.
report.checks.push({ method, path, expectedFailure, http: result.http, code: value?.code, requestId: result.requestId, traceId: result.traceId, passed: expectedFailure ? !ok(result) && ['A0400','A0422','A0409','A0404','A0403','400','409','404','403'].includes(String(value?.code)) : ok(result), message: ok(result) ? undefined : value?.message })
if (expectedFailure ? report.checks.at(-1).passed !== true : !ok(result)) throw new Error(`${method} ${path}: ${response.status}/${value?.code} ${value?.message}; requestId=${result.requestId}; traceId=${result.traceId}`)
return expectedFailure ? result : value.data
}
function assert(value, name) { report.checks.push({ name, passed: Boolean(value) }); if (!value) throw new Error(name) }
async function save() { await mkdir(resolve(output, '..'), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2)) }
try {
admin = (await call('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })).accessToken
const tenants = await call(admin,'GET','/api/platform/v1/iam/tenants?page=1&size=200')
const customer = tenants.records.find(row => row.status === 'ACTIVE' && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || row.id === process.env.OVERSEAS_TEST_CUSTOMER_ID))
assert(customer?.id, 'Existing active local customer')
const customerId = customer.id
const warehouses = (await call(admin,'GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records
const warehouse = warehouses.find(row => row.warehouseCode === 'US-LAX-01') || warehouses[0]
assert(warehouse?.id, 'Existing overseas warehouse')
report.customerId = customerId; report.warehouseId = warehouse.id
const roles = await call(admin,'GET','/api/platform/v1/iam/roles')
const role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE')
assert(role?.id, 'Existing PDA role')
const username = `pda_box_${marker.replace(/\D/g,'').slice(-18)}`
const password = process.env.LOCAL_TRANSFER_PDA_PASSWORD
if (!password) throw new Error('Explicit fixture PDA password required')
const user = await call(admin,'POST','/api/platform/v1/iam/users', { username, password, displayName: marker, phone: `18${randomInt(100000000,999999999)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status:'ACTIVE' })
userId = user.membershipId
pda = (await call('', 'POST', '/api/auth/login', { loginId: username, password })).accessToken
assert(pda, 'Dedicated warehouse-bound PDA session')
await writeFile(resolve('.env.transfer-carton-session'), `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\n`, { mode: 0o600 })
for (const scenario of [
{ name:'single-box', counts:[1], actual:1 },
{ name:'multi-box-multi-sku', counts:[2,1], actual:3, pallets:[1,2] },
{ name:'short-receipt', counts:[3], actual:2, reason:'验收短收一箱' },
{ name:'over-receipt', counts:[2], actual:3, reason:'验收超收一箱' }
]) {
const key = `${marker}-${scenario.name}`
const items = scenario.counts.map((packageCount,index) => ({ skuCode:`${key}-SKU${index+1}`,productNameZh:`验收货物${index+1}`,quantity:120+index*30,packageCount,shippingMark:`${key}-CUSTOMER${index+1}` }))
let detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, { operatingWarehouseId:warehouse.id,destinationType:'PRIVATE',privateDestinationAddress:'100 Test Warehouse Road',privateDestinationPostalCode:'90001',privateDestinationContactName:'测试收件人',privateDestinationContactPhone:'0000000000',deliveryAddress:'100 Test Warehouse Road',pickupContactName:'测试操作员',pickupContactPhone:'0000000000',packageCount:scenario.counts.reduce((a,b)=>a+b,0),volumeCbm:0.1,deliveryMethod:'TRUCK',estimatedArrivalAt:new Date().toISOString().slice(0,19),estimatedShipAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:key,needRelabel:false,idempotencyKey:key,items })
assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity && item.packageCount === items[index].packageCount), 'SKU declared units and expected cartons persist independently')
const no = detail.transferNo
const fact = { scenario: scenario.name, transferNo:no, plannedBoxes:detail.packageCount, actualBoxes:scenario.actual, status:detail.status, palletNos:[] }; report.orders.push(fact); await save()
detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers/${no}/review?customerId=${customerId}`,{decision:'APPROVE',remark:key})
assert(detail.status === 'WAIT_PDA_RECEIVING', 'Review enters PDA receiving')
const path = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(no)}`
await call(pda,'POST',`${path}/palletize`,{idempotencyKey:`${key}-premature`,photoFileIds:['1']},true)
detail = await call(pda,'GET',path)
const palletAssignments = [];
for (const [index, cartonCount] of (scenario.pallets || [scenario.actual]).entries()) {
detail = await call(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:`${key}-pallet${index}`})
let pallet = detail.pallets.find(row=>row.status==='DRAFT')
assert(pallet?.id,'Saved pallet draft')
detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:`${key}-generate${index}`})
pallet=detail.pallets.find(row=>row.id===pallet.id)
detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:`${key}-bind${index}`,palletCode:pallet.palletNo})
fact.palletNos.push(pallet.palletNo); palletAssignments.push({ id: pallet.id, count: cartonCount })
}
let allocationIndex = 0;
const palletIds = palletAssignments.flatMap(pallet => Array(pallet.count).fill(pallet.id));
let boxes = detail.cartons.slice(0,scenario.actual).map((box,index)=>({ cartonMark:box.cartonMark,transferItemId:box.transferItemId,transferPalletId:palletIds[allocationIndex++],length:30+index,width:20+index,height:15+index,weight:2+index,skuCode:detail.items.find(row=>row.id===box.transferItemId).skuCode,quantity:5+index }))
if(scenario.actual>boxes.length) boxes.push({cartonMark:null,transferItemId:detail.items[0].id,transferPalletId:palletIds[allocationIndex++],length:44,width:33,height:22,weight:4.5,skuCode:detail.items[0].skuCode,quantity:13})
const count = {idempotencyKey:`${key}-count`,boxes,discrepancyReason:scenario.reason || null}
await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-bad-sku`,boxes:boxes.map((row,index)=>index?row:{...row,skuCode:'WRONG-SKU'})},true)
if (scenario.reason) await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-missing-reason`,discrepancyReason:null},true)
await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-zero`,boxes:boxes.map((row,index)=>index?row:{...row,quantity:0})},true)
detail = await call(pda,'POST',`${path}/cartons/count`,count)
assert(detail.packageCount === scenario.counts.reduce((a,b)=>a+b,0), 'Actual receipt preserves declared expected carton count')
assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity), 'PDA actual units do not overwrite SKU declared units')
const counted = detail.cartons.filter(row=>row.status==='COUNTED')
for (const pallet of detail.pallets) {
const assigned = counted.filter(box => String(box.transferPalletId) === String(pallet.id));
assert(assigned.length === pallet.cartonCount, 'Pallet count derived from assigned boxes');
assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.length)*Number(box.width)*Number(box.height)/1000000,0)-Number(pallet.volumeCbm)) < 0.000001, 'Pallet volume derived from boxes');
assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.weight),0)-Number(pallet.grossWeightKg)) < 0.001, 'Pallet weight derived from boxes');
}
assert(counted.length===scenario.actual && counted.every(row=>row.countedAt && Number(row.quantity)>0 && Number(row.length)>0), `${scenario.name} persisted box measurements and quantities`)
fact.boxes = counted.map(row=>({cartonMark:row.cartonMark,length:row.length,width:row.width,height:row.height,weight:row.weight,quantity:row.quantity}))
assert(detail.cartons.filter(row=>row.status==='NOT_RECEIVED').length===Math.max(0,detail.packageCount-scenario.actual),'Missing box facts retained')
const replay = await call(pda,'POST',`${path}/cartons/count`,count)
assert(JSON.stringify(replay.cartons)===JSON.stringify(detail.cartons),'Count replay preserves original box facts')
await call(pda,'POST',`${path}/cartons/count`,{...count,boxes:boxes.map((row,index)=>index?row:{...row,quantity:row.quantity+1})},true)
const label = await call(pda,'GET',`${path}/carton-label`)
assert(counted.every(row=>label.printableHtml.includes(row.cartonMark)), 'Printable labels include every received carton')
const form = new FormData();form.append('file',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')],{type:'image/png'}),`${key}.png`)
const photo=await call(pda,'POST',`${path}/measurement-photos/files`,form)
const complete={idempotencyKey:`${key}-complete`,photoFileIds:[photo.fileId]}
detail=await call(pda,'POST',`${path}/palletize`,complete)
assert(detail.status==='WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus==='INBOUND_COMPLETED','Completed receiving waits for customer confirmation')
assert(detail.pallets.reduce((sum,row)=>sum+Number(row.cartonCount),0)===scenario.actual,'Pallet box total equals counted receipts')
await call(pda,'POST',`${path}/palletize`,complete)
const readback=await call(admin,'GET',`/api/platform/v1/last-leg/warehouse-transfers/${no}?customerId=${customerId}`)
assert(JSON.stringify(readback.cartons)===JSON.stringify(detail.cartons),'Platform readback matches persisted PDA box facts')
assert(readback.pallets.every(row=>row.status==='COMPLETED'),'Completed pallets persisted')
fact.status=readback.status;fact.orderStatus=readback.orderStatus;fact.passed=true
await save()
}
report.passed = report.orders.length===4 && report.orders.every(row=>row.passed) && report.checks.every(row=>row.passed)
} catch(error) { report.error=error.message; process.exitCode=1 }
finally {
if(userId && process.env.TRANSFER_KEEP_PDA !== 'true') { try {await call(admin,'PUT',`/api/platform/v1/iam/users/${userId}/status`,{status:'DISABLED'});report.fixturePdaDisabled=true} catch(error) {report.cleanupError=error.message;report.passed=false;process.exitCode=1} }
if(userId) report.fixturePdaMembershipId=userId
await save();console.log(JSON.stringify({passed:report.passed,output,orders:report.orders.map(row=>({scenario:row.scenario,transferNo:row.transferNo,status:row.status,passed:row.passed})),checks:report.checks.length,error:report.error}))
}