merge: integrate TEST updates and verify appointment release
This commit is contained in:
@@ -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 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。
|
||||
|
||||
自营库存周转后端导出权限见 `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;不回填历史废弃单。
|
||||
|
||||
+21
@@ -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;
|
||||
+24
@@ -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;
|
||||
+13
@@ -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;
|
||||
+3
@@ -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,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_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,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,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));
|
||||
|
||||
@@ -48,6 +48,7 @@ CREATE TABLE ll_cargo_discard_carton (
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||
carton_id bigint NOT NULL COMMENT '箱唛记录ID',
|
||||
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 '创建时间',
|
||||
PRIMARY KEY (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',
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT 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',
|
||||
inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照',
|
||||
profile_id bigint DEFAULT NULL COMMENT '托盘档案ID',
|
||||
sku_inventory_id bigint DEFAULT NULL COMMENT 'SKU库存记录ID',
|
||||
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
|
||||
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 '可用箱数快照',
|
||||
discard_packages int NOT NULL COMMENT '申请废弃箱数',
|
||||
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 '创建时间',
|
||||
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)
|
||||
,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_location_id bigint DEFAULT NULL COMMENT '海外库位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 '乐观锁版本',
|
||||
bound_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),
|
||||
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)),
|
||||
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity >= 0)),
|
||||
packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数'
|
||||
) 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)
|
||||
) 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 (
|
||||
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='自营领星商品资料版本';
|
||||
|
||||
|
||||
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||
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;
|
||||
|
||||
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ class TerminalDrayageOpenApiContractTest {
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
|
||||
"船节点跟踪任务尚未建立也返回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:",
|
||||
"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),
|
||||
(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),
|
||||
(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),
|
||||
(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)
|
||||
@@ -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:create',
|
||||
'merchant:overseas-warehouse:discard:cancel',
|
||||
'merchant:overseas-warehouse:discard:confirm',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
'merchant:overseas-warehouse:return-order:create',
|
||||
'merchant:overseas-warehouse:return-order:relabel'
|
||||
@@ -371,9 +373,10 @@ BEGIN
|
||||
'merchant:overseas-warehouse:discard:list',
|
||||
'merchant:overseas-warehouse:discard:create',
|
||||
'merchant:overseas-warehouse:discard:cancel',
|
||||
'merchant:overseas-warehouse:discard:confirm',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
'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='海外客户权限初始化失败:权限资源不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
@@ -405,9 +408,10 @@ BEGIN
|
||||
'merchant:overseas-warehouse:discard:list',
|
||||
'merchant:overseas-warehouse:discard:create',
|
||||
'merchant:overseas-warehouse:discard:cancel',
|
||||
'merchant:overseas-warehouse:discard:confirm',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
'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='海外客户权限初始化失败:客户默认管理员授权不完整';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ class MerchantReturnInventoryPermissionSqlContractTest {
|
||||
assertThat(sql).contains(
|
||||
"(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'",
|
||||
"role.role_code='MERCHANT_OWNER'",
|
||||
")) <> 24 THEN",
|
||||
")) <> 25 THEN",
|
||||
"客户默认管理员授权不完整");
|
||||
|
||||
int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
|
||||
|
||||
+2
-1
@@ -36,6 +36,7 @@ class OverseasCustomerPermissionSqlContractTest {
|
||||
"merchant:overseas-warehouse:discard:list",
|
||||
"merchant:overseas-warehouse:discard:create",
|
||||
"merchant:overseas-warehouse:discard:cancel",
|
||||
"merchant:overseas-warehouse:discard:confirm",
|
||||
"merchant:overseas-warehouse:return-order:list",
|
||||
"merchant:overseas-warehouse:return-order:create",
|
||||
"merchant:overseas-warehouse:return-order:relabel");
|
||||
@@ -85,7 +86,7 @@ class OverseasCustomerPermissionSqlContractTest {
|
||||
"海外客户权限初始化失败:客户默认管理员授权不完整",
|
||||
"permission.app_code='MERCHANT'",
|
||||
"role.role_code='MERCHANT_OWNER'",
|
||||
")) <> 24 THEN");
|
||||
")) <> 25 THEN");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -1158,6 +1158,7 @@ public class DeliveryTransferService {
|
||||
required(request == null ? null : request.reason(), "取消原因不能为空");
|
||||
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
|
||||
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.
|
||||
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
|
||||
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
|
||||
@@ -1425,6 +1426,7 @@ public class DeliveryTransferService {
|
||||
new LambdaQueryWrapper<OutboundOrder>().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
|
||||
.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.status()), OutboundOrder::getStatus, query.status())
|
||||
.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.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) {
|
||||
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
|
||||
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
|
||||
|
||||
-7
@@ -77,13 +77,6 @@ public class CargoDiscardController {
|
||||
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) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
|
||||
+6
@@ -46,6 +46,12 @@ public class MerchantCargoDiscardController {
|
||||
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());
|
||||
}
|
||||
@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")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+8
-1
@@ -20,7 +20,14 @@ public final class CargoDiscardRequests {
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
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) {}
|
||||
|
||||
|
||||
+24
-3
@@ -11,16 +11,37 @@ public final class CargoDiscardResponses {
|
||||
String inboundCode, String inboundOrderNo,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
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 Summary(Long id, String customerId, String discardNo, String inboundCodes,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
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,
|
||||
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,
|
||||
String remark, LocalDateTime createdAt) {}
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ public class CargoDiscardApplication {
|
||||
private String createdBy;
|
||||
private String reviewedBy;
|
||||
private String reviewRemark;
|
||||
private LocalDateTime customerConfirmedAt;
|
||||
private LocalDateTime reviewedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ public class CargoDiscardCarton {
|
||||
private Long itemId;
|
||||
private String customerId;
|
||||
private Long cartonId;
|
||||
private String sourceType;
|
||||
private String cartonMark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ public class CargoDiscardItem {
|
||||
private Long orderId;
|
||||
private String sourceType;
|
||||
private Long returnCartonId;
|
||||
private Long inventoryPositionId;
|
||||
private String cartonMarksJson;
|
||||
private Boolean cartonScanRequired;
|
||||
private String inboundCode;
|
||||
private Long profileId;
|
||||
private Long skuInventoryId;
|
||||
|
||||
+60
@@ -12,6 +12,66 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
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>0 AND b.available_packages>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," +
|
||||
"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 " +
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ public class PdaCargoDiscardController {
|
||||
}
|
||||
@PostMapping("/resolve") public ApiResponse<PdaCargoDiscardDtos.PalletTask> resolve(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@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)
|
||||
public ApiResponse<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo> uploadPhoto(
|
||||
|
||||
+9
-3
@@ -10,12 +10,18 @@ import java.util.List;
|
||||
|
||||
public final class 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 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,
|
||||
Integer actualPackages, String status) { }
|
||||
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) { }
|
||||
}
|
||||
|
||||
+60
-22
@@ -25,12 +25,14 @@ import java.util.stream.Collectors;
|
||||
public class PdaCargoDiscardService {
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService;
|
||||
@Resource private CargoDiscardApplicationMapper applications;
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper cartons;
|
||||
@Resource private DropshipOrderProfileMapper profiles;
|
||||
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
|
||||
@Resource private CargoDiscardService discardService;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService;
|
||||
|
||||
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
|
||||
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>()
|
||||
@@ -41,7 +43,14 @@ public class PdaCargoDiscardService {
|
||||
}
|
||||
|
||||
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, "该仓库没有此托盘的待废弃任务"));
|
||||
}
|
||||
|
||||
@@ -62,12 +71,19 @@ public class PdaCargoDiscardService {
|
||||
if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) {
|
||||
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<>();
|
||||
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
|
||||
CargoDiscardItem item = items.selectById(line.itemId());
|
||||
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|
||||
|| !Objects.equals(application.getId(), item.getApplicationId())
|
||||
|| !Objects.equals(task.profileId(), isReturnItem(item) ? item.getReturnCartonId() : item.getProfileId()))
|
||||
|| !physicalCode(application, item).equals(task.palletNo()))
|
||||
throw conflict("废弃明细不属于当前申请或托盘");
|
||||
if ("COMPLETED".equals(item.getPdaStatus())
|
||||
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
|
||||
@@ -106,8 +122,9 @@ public class PdaCargoDiscardService {
|
||||
application.setTotalPackages(actualTotal);
|
||||
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
|
||||
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()))
|
||||
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
|
||||
}
|
||||
@@ -142,27 +159,48 @@ public class PdaCargoDiscardService {
|
||||
return application;
|
||||
}
|
||||
|
||||
private static final String LOOSE = "散箱";
|
||||
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
|
||||
return items.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
.collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList()))
|
||||
.entrySet().stream().map(entry -> {
|
||||
CargoDiscardItem first = entry.getValue().get(0);
|
||||
boolean returnSource = isReturnItem(first);
|
||||
DropshipOrderProfile profile = returnSource ? null : profiles.selectById(first.getProfileId());
|
||||
ReturnInventoryCarton returnCarton = returnSource ? returnCartons.selectById(first.getReturnCartonId()) : null;
|
||||
if (!returnSource && (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))) throw conflict("废弃明细关联托盘不存在");
|
||||
if (returnSource && (returnCarton == null || !application.getCustomerId().equals(returnCarton.getCustomerId()))) throw conflict("废弃明细关联退货箱不存在");
|
||||
List<PdaCargoDiscardDtos.Sku> skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(
|
||||
i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(),
|
||||
i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
|
||||
String status = skus.stream().allMatch(s -> "COMPLETED".equals(s.status())) ? "COMPLETED" : "PENDING";
|
||||
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(),
|
||||
application.getOverseasWarehouseId(), returnSource ? firstNonBlank(returnCarton.getPalletCode(), returnCarton.getCartonMark()) : profile.getPalletNo(),
|
||||
returnSource ? returnCarton.getId() : profile.getId(), status, skus);
|
||||
}).toList();
|
||||
var allItems = items.listApplication(application.getCustomerId(), application.getId());
|
||||
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
|
||||
return grouped.entrySet().stream().map(entry -> {
|
||||
CargoDiscardItem first = entry.getValue().get(0);
|
||||
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
|
||||
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
|
||||
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
|
||||
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
|
||||
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
|
||||
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
|
||||
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
|
||||
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
|
||||
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks);
|
||||
}).toList();
|
||||
}
|
||||
private String physicalGroupKey(CargoDiscardItem item) {
|
||||
return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId();
|
||||
private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) {
|
||||
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) {
|
||||
return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null;
|
||||
|
||||
+6
@@ -34,6 +34,7 @@ import java.util.Map;
|
||||
@Service
|
||||
public class CargoDiscardInventoryAdjustmentService {
|
||||
@Resource private CargoDiscardInventoryMapper inventory;
|
||||
@Resource private CargoDiscardStockService stockService;
|
||||
@Resource private DropshipCartonLedgerMapper cartonLedgers;
|
||||
@Resource private DropshipCartonSkuMapper cartonSkus;
|
||||
@Resource private DropshipSkuInventoryMapper skuInventories;
|
||||
@@ -47,6 +48,11 @@ public class CargoDiscardInventoryAdjustmentService {
|
||||
public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application,
|
||||
CargoDiscardItem item, List<CargoDiscardCarton> links,
|
||||
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 (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量");
|
||||
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
|
||||
|
||||
+124
@@ -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); }
|
||||
}
|
||||
+119
-22
@@ -61,6 +61,10 @@ public class CargoDiscardService {
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED);
|
||||
|
||||
@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 CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper discardCartons;
|
||||
@@ -72,6 +76,8 @@ public class CargoDiscardService {
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@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}")
|
||||
private BigDecimal feePerPackage = new BigDecimal("5.00");
|
||||
|
||||
@@ -85,6 +91,7 @@ public class CargoDiscardService {
|
||||
List<CargoDiscardResponses.Candidate> result = grouped.values().stream()
|
||||
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
|
||||
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
|
||||
result.addAll(stockService.candidates(customerId, trim(inboundCode)));
|
||||
String keyword = trim(inboundCode);
|
||||
if (returnCartons == null || returnCartonItems == null) return result;
|
||||
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) {
|
||||
@@ -100,7 +107,35 @@ public class CargoDiscardService {
|
||||
carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
|
||||
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
|
||||
@@ -116,6 +151,7 @@ public class CargoDiscardService {
|
||||
return detail(customerId, replay.getDiscardNo());
|
||||
}
|
||||
Set<String> requestedDimensions = new LinkedHashSet<>();
|
||||
Set<String> requestedBoxes = new LinkedHashSet<>();
|
||||
Map<Long, LastlegOrder> orderCache = new HashMap<>();
|
||||
Map<Long, ReturnInventoryCarton> returnCartonCache = new HashMap<>();
|
||||
String warehouseId = null;
|
||||
@@ -123,19 +159,29 @@ public class CargoDiscardService {
|
||||
int total = 0;
|
||||
for (CargoDiscardRequests.Item input : request.items()) {
|
||||
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");
|
||||
}
|
||||
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();
|
||||
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
|
||||
String itemWarehouseId;
|
||||
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());
|
||||
ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim());
|
||||
if (sku == null || value(sku.getQuantity()) < input.discardPackages()) {
|
||||
throw conflict("Insufficient return inventory for SKU " + input.skuCode().trim());
|
||||
if (sku == null || value(sku.getQuantity()) != input.discardPackages()) {
|
||||
throw conflict("目前仅支持全部废弃,退货 SKU 数量不匹配: " + input.skuCode().trim());
|
||||
}
|
||||
returnCartonCache.put(carton.getId(), carton);
|
||||
itemWarehouseId = carton.getOverseasWarehouseId();
|
||||
@@ -155,13 +201,23 @@ public class CargoDiscardService {
|
||||
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();
|
||||
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
|
||||
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
|
||||
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
|
||||
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))
|
||||
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
|
||||
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
|
||||
@@ -176,7 +232,9 @@ public class CargoDiscardService {
|
||||
|
||||
Set<Long> reservedReturnCartons = new LinkedHashSet<>();
|
||||
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,
|
||||
reservedReturnCartons);
|
||||
} else {
|
||||
@@ -187,6 +245,16 @@ public class CargoDiscardService {
|
||||
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) {
|
||||
validateQuery(query);
|
||||
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
|
||||
@@ -200,7 +268,9 @@ public class CargoDiscardService {
|
||||
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
|
||||
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -209,7 +279,7 @@ public class CargoDiscardService {
|
||||
if (application == null) throw notFound("Cargo discard application not found");
|
||||
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
|
||||
.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();
|
||||
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
|
||||
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
|
||||
@@ -244,9 +314,10 @@ public class CargoDiscardService {
|
||||
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
|
||||
String historyRemark = request.remark();
|
||||
if (REJECTED.equals(next)) {
|
||||
release(customerId, links, applicationItems);
|
||||
release(customerId, application, links, applicationItems);
|
||||
}
|
||||
|
||||
if (APPROVED.equals(next)) outboundService.create(application, operatorId);
|
||||
LocalDateTime reviewedAt = now();
|
||||
application.setStatus(next); application.setReviewedBy(operatorId);
|
||||
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
|
||||
@@ -266,12 +337,13 @@ public class CargoDiscardService {
|
||||
}
|
||||
|
||||
@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);
|
||||
if (application == null) throw notFound("Cargo discard application not found");
|
||||
if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -287,6 +359,7 @@ public class CargoDiscardService {
|
||||
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);
|
||||
}
|
||||
outboundService.sync(application);
|
||||
history(customerId, application.getId(), from, to, operatorId, remark, at);
|
||||
}
|
||||
|
||||
@@ -304,7 +377,7 @@ public class CargoDiscardService {
|
||||
throw conflict("客户只能取消待审核的废弃申请");
|
||||
}
|
||||
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();
|
||||
String from = application.getStatus();
|
||||
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);
|
||||
orderHistories.insert(h);
|
||||
}
|
||||
if (APPROVED.equals(from)) outboundService.sync(application);
|
||||
history(customerId, application.getId(), from, CANCELLED, operatorId, "废弃申请已取消", at);
|
||||
return detail(customerId, discardNo);
|
||||
}
|
||||
@@ -325,11 +399,14 @@ public class CargoDiscardService {
|
||||
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
|
||||
CargoDiscardRequests.Item input, LocalDateTime now) {
|
||||
String skuCode = input.skuCode().trim();
|
||||
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) {
|
||||
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode());
|
||||
List<DropshipCarton> selected;
|
||||
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("Available cargo changed; retry the discard request");
|
||||
if (selected.size() != input.discardPackages()) throw conflict("箱子已被占用、SKU 不匹配或不属于当前库存,请刷新后重选");
|
||||
Map<Long, List<DropshipCarton>> byProfile = selected.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
|
||||
java.util.stream.Collectors.toList()));
|
||||
@@ -343,6 +420,7 @@ public class CargoDiscardService {
|
||||
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
|
||||
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
|
||||
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);
|
||||
for (DropshipCarton carton : entry.getValue()) {
|
||||
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) {
|
||||
if (genericItems.contains(link.getItemId())) continue;
|
||||
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
|
||||
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)
|
||||
.filter(java.util.Objects::nonNull).distinct().forEach(cartonId -> {
|
||||
if (returnCartons.releaseDiscard(cartonId, customerId) != 1) {
|
||||
@@ -373,7 +454,7 @@ public class CargoDiscardService {
|
||||
private void reserveReturn(String customerId, CargoDiscardApplication application, ReturnInventoryCarton carton,
|
||||
CargoDiscardRequests.Item input, LocalDateTime now, Set<Long> reservedCartons) {
|
||||
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");
|
||||
}
|
||||
if (reservedCartons.add(carton.getId()) && returnCartons.markDiscardPending(carton.getId(), customerId) != 1) {
|
||||
@@ -410,10 +491,26 @@ public class CargoDiscardService {
|
||||
}
|
||||
|
||||
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(),
|
||||
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.getReviewedAt());
|
||||
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
|
||||
}
|
||||
|
||||
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
|
||||
|
||||
+142
@@ -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); }
|
||||
}
|
||||
+2
-2
@@ -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_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_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='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>" +
|
||||
@@ -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 " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
|
||||
+3
-3
@@ -116,8 +116,8 @@ public class DispatchService {
|
||||
if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
|
||||
}
|
||||
if (!"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单不是卡车派送,无需进行排车");
|
||||
if ("EXPRESS".equals(application.getDeliveryMethod())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单为快递派送,无需进行排车");
|
||||
}
|
||||
return application.getCustomerId();
|
||||
}
|
||||
@@ -598,7 +598,7 @@ public class DispatchService {
|
||||
}
|
||||
relabelBindingGuard.requireBound(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, "换标单不是待排车状态");
|
||||
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
|
||||
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
|
||||
|
||||
+34
@@ -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,
|
||||
InventoryCommandRequests.Move request, String skuSnapshot) {
|
||||
return ship(customerId, operatorId, request, skuSnapshot, null);
|
||||
|
||||
+3
-1
@@ -80,6 +80,7 @@ public class PdaRelabelService {
|
||||
return result;
|
||||
}
|
||||
@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 PROCESSING = "PROCESSING";
|
||||
public static final String WAIT_REVIEW = "WAIT_REVIEW";
|
||||
@@ -454,6 +455,7 @@ public class PdaRelabelService {
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
if (!request.manualCompletion()) bindingGuard.requireBound(application);
|
||||
palletMeasurements.requireMeasured(application);
|
||||
List<RelabelItem> detailItems = itemList(application);
|
||||
List<PdaRelabelUnitFact> facts = factList(task);
|
||||
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.getReviewRemark(), task == null ? null : task.getClaimedAt(),
|
||||
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) {
|
||||
|
||||
+5
-5
@@ -78,7 +78,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
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,
|
||||
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 RelabelItemMapper items;
|
||||
@@ -373,7 +373,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
|
||||
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");
|
||||
}
|
||||
String requestHash = hash(request);
|
||||
@@ -951,9 +951,9 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setCustomerConfirmationKey(key);
|
||||
application.setCustomerConfirmedAt(now);
|
||||
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");
|
||||
if (requiresOutbound && !existingStockTransfer && !"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
if (requiresOutbound && !existingStockTransfer && "EXPRESS".equals(application.getDeliveryMethod())) {
|
||||
createDirectOutbound(application, operatorId, now);
|
||||
application.setFulfillmentStatus("OUTBOUND_CREATED");
|
||||
}
|
||||
@@ -993,7 +993,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
|
||||
// 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。
|
||||
// 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。
|
||||
boolean awaitingTruck = requiresOutbound && "TRUCK".equals(application.getDeliveryMethod());
|
||||
boolean awaitingTruck = requiresOutbound && !"EXPRESS".equals(application.getDeliveryMethod());
|
||||
if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) {
|
||||
carton.setRelabelApplicationId(null); carton.setRelabelNo(null);
|
||||
}
|
||||
|
||||
+2
-2
@@ -58,7 +58,7 @@ public class RelabelBindingGuard {
|
||||
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 (!"TRUCK".equals(app.getDeliveryMethod())) continue;
|
||||
if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
|
||||
if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()))
|
||||
throw conflict("退货箱尚未完成排车托盘绑定");
|
||||
continue;
|
||||
@@ -76,7 +76,7 @@ public class RelabelBindingGuard {
|
||||
var actual = new HashMap<String, Long>();
|
||||
stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum));
|
||||
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());
|
||||
if (carton == null || carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|
||||
|| !"PROCESSING".equals(carton.getRelabelStatus()))
|
||||
|
||||
+13
-3
@@ -10,6 +10,13 @@ import java.util.List;
|
||||
/** Immutable movements retain original return ownership when cartons are merged again. */
|
||||
@Mapper
|
||||
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,
|
||||
String skuCode, long quantity, LocalDateTime createdAt) { }
|
||||
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")
|
||||
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);
|
||||
|
||||
@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);
|
||||
|
||||
@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);
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
+7
-1
@@ -1190,7 +1190,13 @@ public class ReturnInventoryService {
|
||||
if (origins.length != 1) throw conflict("历史混箱缺少逐 SKU 来源明细,不能继续合并");
|
||||
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) {
|
||||
|
||||
@@ -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_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_CONFIRM = "merchant:overseas-warehouse:discard:confirm";
|
||||
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_CREATE = "merchant:overseas-warehouse:return-order:create";
|
||||
|
||||
+15
@@ -77,6 +77,21 @@ public class PdaWarehouseTransferController {
|
||||
.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")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
|
||||
+26
-9
@@ -97,6 +97,24 @@ public final class WarehouseTransferRequests {
|
||||
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@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(
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
|
||||
@@ -104,13 +122,7 @@ public final class WarehouseTransferRequests {
|
||||
|
||||
public record PalletDraft(
|
||||
Long palletId,
|
||||
@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
|
||||
@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 = 255) String productNameZh,
|
||||
@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(
|
||||
@NotBlank @Size(max = 32) String operationType,
|
||||
|
||||
+4
-2
@@ -6,8 +6,10 @@ import java.util.List;
|
||||
|
||||
public final class WarehouseTransferResponses {
|
||||
private WarehouseTransferResponses() {}
|
||||
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {}
|
||||
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {}
|
||||
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,
|
||||
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 DestinationResult(String customerId, String transferNo) {}
|
||||
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
|
||||
|
||||
+11
@@ -13,12 +13,23 @@ public class WarehouseTransferCarton {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long transferOrderId;
|
||||
private Long transferItemId;
|
||||
private Long transferPalletId;
|
||||
private String customerId;
|
||||
private String cartonMark;
|
||||
private String customerCartonMark;
|
||||
private Integer cartonIndex;
|
||||
private Integer totalCartons;
|
||||
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 updatedAt;
|
||||
}
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ public class WarehouseTransferItem {
|
||||
private String skuCode;
|
||||
private String productNameZh;
|
||||
private Integer packageCount;
|
||||
private Long declaredQuantity;
|
||||
private String shippingMark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
+133
-42
@@ -525,7 +525,7 @@ public class WarehouseTransferService {
|
||||
new LambdaQueryWrapper<WarehouseTransferItem>().eq(WarehouseTransferItem::getCustomerId, customerId)
|
||||
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId))
|
||||
.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()
|
||||
.collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity()));
|
||||
List<WarehouseTransferResponses.SourceOrder> sources = sourceOrders.selectList(
|
||||
@@ -535,7 +535,7 @@ public class WarehouseTransferService {
|
||||
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
|
||||
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();
|
||||
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.of() : transferPalletItems.listOrder(customerId, transfer.getId());
|
||||
Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream()
|
||||
@@ -607,13 +607,21 @@ public class WarehouseTransferService {
|
||||
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)
|
||||
public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) {
|
||||
WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo);
|
||||
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");
|
||||
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();
|
||||
for (WarehouseTransferResponses.Carton carton : cartons) {
|
||||
labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>")
|
||||
@@ -689,9 +697,10 @@ public class WarehouseTransferService {
|
||||
WarehouseTransferRequests.PalletDraft request) {
|
||||
WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo);
|
||||
String customerId = transfer.getCustomerId();
|
||||
validatePalletMeasurement(request.lengthCm(), request.widthCm(), request.heightCm(), request.volumeCbm());
|
||||
String requestHash = hash(request);
|
||||
List<WarehouseTransferPallet> existing = transferPallets.listOrder(customerId, transfer.getId());
|
||||
if (transferCartons.listOrder(customerId, transfer.getId()).stream().anyMatch(box -> box.getCountedAt() != null))
|
||||
throw conflict("箱子清点已确认,不能再新增或修改托盘");
|
||||
WarehouseTransferPallet pallet;
|
||||
if (request.palletId() == null) {
|
||||
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 (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked");
|
||||
}
|
||||
pallet.setCartonCount(request.cartonCount()); pallet.setLengthCm(request.lengthCm());
|
||||
pallet.setWidthCm(request.widthCm()); pallet.setHeightCm(request.heightCm());
|
||||
pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg());
|
||||
pallet.setCartonCount(0); pallet.setLengthCm(BigDecimal.ZERO);
|
||||
pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO);
|
||||
pallet.setVolumeCbm(BigDecimal.ZERO); pallet.setGrossWeightKg(BigDecimal.ZERO);
|
||||
pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash);
|
||||
pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet);
|
||||
fleetPool.receiving(customerId, transferNo, pdaOperatorId());
|
||||
@@ -782,6 +791,94 @@ public class WarehouseTransferService {
|
||||
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) {
|
||||
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
|
||||
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||
@@ -818,6 +915,7 @@ public class WarehouseTransferService {
|
||||
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
|
||||
throw conflict("Warehouse transfer is not awaiting PDA receiving");
|
||||
}
|
||||
List<WarehouseTransferCarton> countedCartons = requireCountedCartons(transfer);
|
||||
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
|
||||
.eq(WarehouseTransferItem::getCustomerId, customerId)
|
||||
.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()))) {
|
||||
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();
|
||||
List<DerivedPalletAllocation> derivedAllocations = derivePersistedPalletAllocations(items, persistedPallets);
|
||||
if (palletCartons != countedCartons.size()) throw conflict("打托箱数必须与已清点实收箱数一致");
|
||||
List<DerivedPalletAllocation> derivedAllocations = deriveCountedPalletAllocations(countedCartons, persistedPallets);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<WarehouseTransferPalletItem> completedPalletItems = new ArrayList<>();
|
||||
for (int index = 0; index < persistedPallets.size(); index++) {
|
||||
@@ -1452,29 +1552,20 @@ public class WarehouseTransferService {
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<DerivedPalletAllocation> derivePersistedPalletAllocations(List<WarehouseTransferItem> items,
|
||||
List<WarehouseTransferPallet> pallets) {
|
||||
if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete");
|
||||
static List<DerivedPalletAllocation> deriveCountedPalletAllocations(List<WarehouseTransferCarton> cartons,
|
||||
List<WarehouseTransferPallet> pallets) {
|
||||
List<DerivedPalletAllocation> result = new ArrayList<>();
|
||||
int itemIndex = 0;
|
||||
int itemRemaining = items.get(0).getPackageCount();
|
||||
for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) {
|
||||
int palletRemaining = pallets.get(palletIndex).getCartonCount();
|
||||
while (palletRemaining > 0) {
|
||||
WarehouseTransferItem item = items.get(itemIndex);
|
||||
if (itemIndex + 1 == items.size()) {
|
||||
result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining));
|
||||
break;
|
||||
}
|
||||
int allocated = Math.min(palletRemaining, itemRemaining);
|
||||
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();
|
||||
}
|
||||
}
|
||||
Set<Long> palletIds = pallets.stream().map(WarehouseTransferPallet::getId).collect(Collectors.toSet());
|
||||
if (cartons.stream().anyMatch(box -> box.getTransferPalletId() == null || !palletIds.contains(box.getTransferPalletId())))
|
||||
throw conflict("每个实收箱必须分配到本单托盘");
|
||||
for (int index = 0; index < pallets.size(); index++) {
|
||||
var pallet = pallets.get(index);
|
||||
Map<Long, Integer> quantities = new java.util.LinkedHashMap<>();
|
||||
cartons.stream().filter(box -> pallet.getId().equals(box.getTransferPalletId()))
|
||||
.forEach(box -> quantities.merge(box.getTransferItemId(), 1, Integer::sum));
|
||||
if (quantities.values().stream().mapToInt(Integer::intValue).sum() != pallet.getCartonCount())
|
||||
throw conflict("托盘箱数与分配的实收箱不一致");
|
||||
for (var entry : quantities.entrySet()) result.add(new DerivedPalletAllocation(index, entry.getKey(), entry.getValue()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1796,18 +1887,17 @@ public class WarehouseTransferService {
|
||||
pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED");
|
||||
pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm());
|
||||
pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet);
|
||||
for (WarehouseTransferPalletItem sourceItem : palletItems.stream()
|
||||
.filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) {
|
||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
||||
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
|
||||
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
|
||||
allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount());
|
||||
// PDA 已完成测量、打托并确认了本托箱数;入仓计划列表的“实际拣货数”
|
||||
// 直接汇总该字段,不能再保留初始化时的 0。
|
||||
allocation.setPickedPackageCount(sourceItem.getPackageCount());
|
||||
allocation.setActualVolumeCbm(source.getVolumeCbm());
|
||||
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
|
||||
}
|
||||
// Plan allocations are unique by inbound line + pallet. SKU breakdown
|
||||
// belongs to transfer pallet items and must not duplicate that allocation.
|
||||
int receivedPackages = palletItems.stream().filter(row -> row.getTransferPalletId().equals(source.getId()))
|
||||
.mapToInt(WarehouseTransferPalletItem::getPackageCount).sum();
|
||||
if (receivedPackages != source.getCartonCount()) throw conflict("托盘明细箱数与实收不一致");
|
||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
||||
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
|
||||
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
|
||||
allocation.setPlannedPackageCount(receivedPackages); allocation.setCountedPackageCount(receivedPackages);
|
||||
allocation.setPickedPackageCount(receivedPackages); allocation.setActualVolumeCbm(source.getVolumeCbm());
|
||||
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
|
||||
}
|
||||
plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size());
|
||||
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.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED"));
|
||||
item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物"));
|
||||
item.setDeclaredQuantity(source.quantity());
|
||||
item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now);
|
||||
transferItems.insert(item);
|
||||
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',
|
||||
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',
|
||||
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 '完成入库时间',
|
||||
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),
|
||||
@@ -2647,15 +2647,19 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
|
||||
|
||||
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',
|
||||
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明细';
|
||||
@@ -2663,6 +2667,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
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),
|
||||
@@ -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;
|
||||
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箱内数量',
|
||||
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和数量';
|
||||
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
|
||||
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)
|
||||
) 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 (
|
||||
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
|
||||
|
||||
@@ -9,9 +9,44 @@ info:
|
||||
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
|
||||
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:
|
||||
- bearerAuth: []
|
||||
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:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/DiscardNo'
|
||||
@@ -68,7 +103,16 @@ paths:
|
||||
required: [itemId, actualPackages]
|
||||
properties:
|
||||
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:
|
||||
type: array
|
||||
minItems: 1
|
||||
|
||||
@@ -4,6 +4,67 @@ info:
|
||||
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
|
||||
version: 1.0.0
|
||||
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}:
|
||||
get:
|
||||
summary: 查询单条库存余额详情
|
||||
@@ -1214,7 +1275,7 @@ paths:
|
||||
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
|
||||
post:
|
||||
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: [] }]
|
||||
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
|
||||
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
|
||||
@@ -1883,6 +1944,15 @@ components:
|
||||
description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。
|
||||
required: [receiptCommands]
|
||||
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:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' }
|
||||
@@ -3214,7 +3284,7 @@ components:
|
||||
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
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: 仅快递派送可选,客户确认前必须已上传面单 }
|
||||
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
|
||||
RelabelItem:
|
||||
|
||||
@@ -415,9 +415,10 @@ paths:
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
|
||||
post:
|
||||
summary: 提交现场任务到复核池并直接等待客户确认
|
||||
description: 仓储转运换标须先确认所有涉及托盘的有效实测尺寸和重量,含拆托后原托盘剩余货物;手动完成也不能跳过实测校验。缺失或库存版本变化导致实测失效时,保持处理中并提示补测。
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
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:
|
||||
post:
|
||||
summary: 已停用;客户确认完成复核,退回重贴由平台执行
|
||||
|
||||
+10
@@ -672,6 +672,16 @@ class DeliveryTransferServiceTest {
|
||||
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
|
||||
void dispatchOutboundStillRequiresItsDeliveryDocument() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
|
||||
|
||||
+41
@@ -3,10 +3,51 @@ package com.baiyue.lastleg.discard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
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
|
||||
void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception {
|
||||
String sql;
|
||||
|
||||
+16
@@ -15,6 +15,22 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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 MerchantCargoDiscardController controller = controller();
|
||||
private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query(
|
||||
|
||||
+56
-2
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
|
||||
|
||||
@BeforeEach void setup() {
|
||||
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, "applications", applications);
|
||||
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 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})
|
||||
void threeLocalOrdersUploadConfirmAndReplayWithFullPartialAndZeroActualDisposal(int actual) {
|
||||
@Test
|
||||
void fullDisposalUploadsConfirmsAndReplaysWithoutDoubleDeduction() {
|
||||
int actual = 3;
|
||||
var realPhotos = new com.baiyue.lastleg.discard.service.CargoDiscardPhotoService();
|
||||
var photoMapper = mock(com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper.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));
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+107
@@ -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));
|
||||
}
|
||||
}
|
||||
+76
-3
@@ -47,6 +47,36 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
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 CargoDiscardItemMapper items;
|
||||
@Mock CargoDiscardCartonMapper discardCartons;
|
||||
@@ -57,8 +87,10 @@ class CargoDiscardServiceTest {
|
||||
@Mock LastlegOrderStatusHistoryMapper orderHistories;
|
||||
@Mock ReturnInventoryCartonMapper returnCartons;
|
||||
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@Mock com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory;
|
||||
|
||||
private CargoDiscardService service;
|
||||
private final CargoDiscardOutboundService outboundService = org.mockito.Mockito.mock(CargoDiscardOutboundService.class);
|
||||
private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>();
|
||||
private final List<CargoDiscardItem> storedItems = new ArrayList<>();
|
||||
private final List<CargoDiscardCarton> storedLinks = new ArrayList<>();
|
||||
@@ -67,6 +99,11 @@ class CargoDiscardServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
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, "applications", applications);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
@@ -94,18 +131,20 @@ class CargoDiscardServiceTest {
|
||||
@Test
|
||||
void createReservesExactSkuCartonsWithoutDeductingInventory() {
|
||||
LastlegOrder order = order();
|
||||
order.setOverseasWarehouseId("99"); order.setOverseasWarehouseName(null);
|
||||
DropshipSkuInventory sku = skuInventory();
|
||||
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.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);
|
||||
|
||||
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
|
||||
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged"));
|
||||
|
||||
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
|
||||
assertEquals("洛杉矶仓", storedApplication.get().getOverseasWarehouseName());
|
||||
assertEquals(2, detail.summary().totalPackages());
|
||||
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
|
||||
assertEquals(2, storedLinks.size());
|
||||
@@ -113,6 +152,38 @@ class CargoDiscardServiceTest {
|
||||
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
|
||||
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
|
||||
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
|
||||
@@ -132,7 +203,7 @@ class CargoDiscardServiceTest {
|
||||
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
||||
carton.setId(901L);
|
||||
carton.setCustomerId("100");
|
||||
carton.setOverseasWarehouseId("WH-1");
|
||||
carton.setOverseasWarehouseId("99");
|
||||
carton.setSourceReturnNo("RT-20260928-001");
|
||||
carton.setCartonMark("RC-LAX-001");
|
||||
carton.setStatus("AVAILABLE");
|
||||
@@ -149,6 +220,7 @@ class CargoDiscardServiceTest {
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("RETURN_CARTON", result.get(0).sourceType());
|
||||
assertEquals("洛杉矶仓", result.get(0).overseasWarehouseName());
|
||||
assertEquals(901L, result.get(0).returnCartonId());
|
||||
assertEquals("RC-LAX-001", result.get(0).cartonMark());
|
||||
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"));
|
||||
|
||||
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
|
||||
verify(outboundService).create(application, "9");
|
||||
verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any());
|
||||
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
|
||||
assertEquals("ok", storedHistories.get(storedHistories.size() - 1).getRemark());
|
||||
|
||||
+132
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
+29
@@ -594,6 +594,35 @@ class InventoryCommandServiceTest {
|
||||
"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) {
|
||||
InventoryBalance value = new InventoryBalance();
|
||||
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
|
||||
|
||||
+34
@@ -53,6 +53,7 @@ class PdaRelabelServiceTest {
|
||||
@Mock RelabelApplicationService applicationService;
|
||||
@Mock RelabelPhotoFileGateway photoFiles;
|
||||
@Mock com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
|
||||
@Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements;
|
||||
PdaRelabelService service;
|
||||
RelabelApplication application;
|
||||
RelabelItem item;
|
||||
@@ -62,6 +63,7 @@ class PdaRelabelServiceTest {
|
||||
void setUp() {
|
||||
service = new PdaRelabelService();
|
||||
ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
|
||||
ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements);
|
||||
ReflectionTestUtils.setField(service, "applications", applications);
|
||||
ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
@@ -452,12 +454,44 @@ class PdaRelabelServiceTest {
|
||||
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
|
||||
|
||||
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());
|
||||
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
|
||||
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
|
||||
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
|
||||
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1",
|
||||
|
||||
+41
@@ -373,6 +373,47 @@ class ReturnInventoryServiceTest {
|
||||
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
|
||||
void partiallyConsumedSourceUsesCurrentLockedSkuQuantityNotEarlierSnapshot() {
|
||||
ReturnInventoryCarton locked = prepareConcurrentSource();
|
||||
|
||||
+101
@@ -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; }
|
||||
}
|
||||
+10
@@ -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
|
||||
void cartonRelabelAllowsOriginalSkuToBeOmitted() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
|
||||
+108
@@ -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));
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -48,19 +48,17 @@ class WarehouseTransferPalletAllocationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistedOverReceiptUsesTheSameStableAllocationRule() {
|
||||
WarehouseTransferPallet first = persistedPallet(4);
|
||||
WarehouseTransferPallet second = persistedPallet(3);
|
||||
|
||||
var allocations = WarehouseTransferService.derivePersistedPalletAllocations(
|
||||
List.of(item(11L, 3), item(12L, 2)), List.of(first, second));
|
||||
|
||||
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum());
|
||||
assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L)
|
||||
.mapToInt(value -> value.packageCount()).sum());
|
||||
assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L)
|
||||
.mapToInt(value -> value.packageCount()).sum());
|
||||
assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList());
|
||||
void countedBoxesDetermineSkuPlacementRatherThanDeclarationOrder() {
|
||||
WarehouseTransferPallet first = persistedPallet(2); first.setId(100L);
|
||||
WarehouseTransferPallet second = persistedPallet(1); second.setId(200L);
|
||||
var a = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); a.setTransferItemId(12L); a.setTransferPalletId(100L);
|
||||
var b = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); b.setTransferItemId(12L); b.setTransferPalletId(100L);
|
||||
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(List.of(new WarehouseTransferService.DerivedPalletAllocation(0,12L,2),new WarehouseTransferService.DerivedPalletAllocation(1,11L,1)),allocations);
|
||||
c.setTransferPalletId(999L);
|
||||
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second)));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+8
@@ -322,6 +322,10 @@ class WarehouseTransferServiceTest {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
||||
transfer.setOperatingWarehouseId("77");
|
||||
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(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5)));
|
||||
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
|
||||
@@ -366,6 +370,10 @@ class WarehouseTransferServiceTest {
|
||||
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
|
||||
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",
|
||||
new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)));
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# 后端脚本管理与全量 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`。
|
||||
|
||||
`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+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。
|
||||
|
||||
`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或四端页面通过。
|
||||
|
||||
- `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` 空库基线。
|
||||
|
||||
@@ -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})}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
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 '产品名称',
|
||||
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 '废弃箱数',
|
||||
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明细';
|
||||
@@ -1398,6 +1402,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
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),
|
||||
@@ -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;
|
||||
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箱内数量',
|
||||
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和数量';
|
||||
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 '最后更新时间',
|
||||
@@ -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已扫码确认标签''');
|
||||
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}))
|
||||
}
|
||||
Reference in New Issue
Block a user