Merge origin/dev into test preserving warehouse and PDA workflows

This commit is contained in:
2026-09-14 11:03:47 +08:00
103 changed files with 3033 additions and 225 deletions
+3
View File
@@ -4,6 +4,9 @@ spring.datasource:
username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456}
baiyue:
security:
# 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
customer-dimension-initialization:
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
@@ -17417,6 +17417,13 @@ WHERE p.permission_code LIKE 'platform:lastleg:%';
UPDATE iam_permission SET status='INACTIVE', deleted=1
WHERE permission_code LIKE 'platform:lastleg:%';
DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id=role_permission.permission_id
AND permission.tenant_id=role_permission.tenant_id
WHERE permission.permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list');
UPDATE iam_permission SET status='INACTIVE', deleted=1
WHERE permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list');
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)
@@ -17424,6 +17431,7 @@ VALUES
(5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0),
(5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0),
(5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0),
(5107231,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,0),
(5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0),
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0),
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
@@ -17482,7 +17490,8 @@ VALUES
(5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0),
(5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0),
(5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path),
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
@@ -17670,7 +17679,8 @@ FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-warehouse:last-mile-order:edit',
'platform:overseas-warehouse:inbound-order:create'
'platform:overseas-warehouse:inbound-order:create',
'platform:overseas-warehouse:warehouse-transfer:approve'
)
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
@@ -17801,6 +17811,18 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
'platform:overseas-warehouse:customer-visit:edit')
AND permission.status='ACTIVE' AND permission.deleted=0;
-- 已创建的客户拥有租户级默认管理员角色;权限资源仍引用 tenant_id=0 的公共目录。
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
WHERE role.tenant_id>0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.permission_code IN (
'merchant:warehouse:delivery:list',
'merchant:warehouse:delivery:create')
AND permission.status='ACTIVE' AND permission.deleted=0;
-- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。
DROP TEMPORARY TABLE IF EXISTS tmp_phase5_delivery_role_permission;
CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission (
@@ -17988,13 +18010,16 @@ BEGIN
'platform:overseas-warehouse:customer-visit:edit')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission ON permission.id=grant_row.permission_id
WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT'
IF EXISTS (SELECT role.id FROM iam_role role
LEFT JOIN iam_role_permission grant_row
ON grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id
LEFT JOIN iam_permission permission ON permission.id=grant_row.permission_id
AND permission.permission_code IN (
'merchant:warehouse:delivery:list',
'merchant:warehouse:delivery:create')) <> 2 THEN
'merchant:warehouse:delivery:create')
WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT'
AND role.status='ACTIVE' AND role.deleted=0
GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 2) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整';
END IF;
END$$
@@ -18003,7 +18028,6 @@ CALL assert_phase5_delivery_permissions();
DROP PROCEDURE assert_phase5_delivery_permissions;
DROP TEMPORARY TABLE tmp_phase5_delivery_role_permission;
-- IAM 客户提交即成功,默认尺寸通过持久化任务后台初始化;已有客户按历史事实标记 READY。
USE baiyue_iam;
SET @customer_dimension_status_ddl = (SELECT IF(
+124 -5
View File
@@ -160,7 +160,6 @@ CREATE TABLE IF NOT EXISTS ll_order (
transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称',
fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码',
container_no VARCHAR(64) COMMENT '集装箱号',
container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态',
abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是',
@@ -537,7 +536,6 @@ ALTER TABLE ll_order
MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码',
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号',
MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态',
MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
@@ -1621,6 +1619,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash'));
PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at'));
PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式'''));
PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间'''));
PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1651,6 +1655,45 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报';
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='customer_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN customer_carton_mark VARCHAR(255) NULL COMMENT ''客户原箱唛,用于换标现场绑定'' AFTER carton_mark'));
PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carton_stmt; DEALLOCATE PREPARE transfer_customer_carton_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet (
id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号',
warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码',
warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码',
warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数',
length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg',
volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态',
completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', 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_transfer_pallet_seq(transfer_order_id,sequence_no),
UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id),
KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id),
KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型',
customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU',
new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数',
fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓',
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark),
KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
id BIGINT NOT NULL COMMENT '雪花主键',
transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID',
@@ -2264,6 +2307,7 @@ ALTER TABLE ll_security_projection
-- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN');
SET @platform_admin_overseas_authorities = JSON_ARRAY(
'platform:overseas-warehouse:warehouse-transfer:approve',
'platform:finance:bill:adjust',
'platform:finance:bill:update',
'platform:finance:bill:void',
@@ -2453,10 +2497,12 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
-- 客户退货预报、PDA 签收与仓储费起算批次
CREATE TABLE IF NOT EXISTS ll_return_order (
-- need_relabel is added below for existing deployments
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', lastleg_order_id BIGINT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NULL COMMENT '尾程订单号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
SET @ll_return_order_need_relabel = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='need_relabel'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要换标'' AFTER customer_reference_no')); PREPARE stmt FROM @ll_return_order_need_relabel; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''尾程订单ID'' AFTER customer_reference_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(32) NULL COMMENT ''尾程订单号'' AFTER lastleg_order_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER need_relabel')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -2678,7 +2724,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation (
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空',
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -2687,6 +2733,10 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation (
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
ALTER TABLE wh_pallet_operation
MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空',
MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空';
CREATE TABLE IF NOT EXISTS wh_pallet_batch (
id BIGINT NOT NULL COMMENT '批量托盘预留ID',
warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
@@ -3737,7 +3787,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation (
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空',
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -4826,11 +4876,60 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index),
KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报';
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='customer_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN customer_carton_mark VARCHAR(255) NULL COMMENT ''客户原箱唛,用于换标现场绑定'' AFTER carton_mark'));
PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carton_stmt; DEALLOCATE PREPARE transfer_customer_carton_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet (
id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号',
warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码',
warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态',
completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', 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_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型',
customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数',
fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_item') AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1'));
PREPARE ll_relabel_pre_inbound_stmt FROM @ddl; EXECUTE ll_relabel_pre_inbound_stmt; DEALLOCATE PREPARE ll_relabel_pre_inbound_stmt;
-- 2026-09-10 / 038 补齐跨服务列表查询依赖的历史库字段,重复执行安全。
USE baiyue_warehouse;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='internal_code'), 'SELECT 1', 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code'));
PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt;
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id'));
PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_stmt; DEALLOCATE PREPARE return_order_lastleg_no_stmt;
-- 财务 P0:海外费用保留原币、月度汇率和计费依据。
USE baiyue_lastleg;
DROP PROCEDURE IF EXISTS add_delivery_fee_currency_columns;
DELIMITER $$
CREATE PROCEDURE add_delivery_fee_currency_columns()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_fee') THEN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_fee' AND column_name='original_currency') THEN
ALTER TABLE ll_delivery_fee ADD COLUMN original_currency VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '原币币种' AFTER amount;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_fee' AND column_name='original_amount') THEN
ALTER TABLE ll_delivery_fee ADD COLUMN original_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '原币金额' AFTER original_currency;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_fee' AND column_name='exchange_rate') THEN
ALTER TABLE ll_delivery_fee ADD COLUMN exchange_rate DECIMAL(18,8) NOT NULL DEFAULT 1 COMMENT '原币折算CNY汇率' AFTER original_amount;
END IF;
END IF;
END$$
DELIMITER ;
CALL add_delivery_fee_currency_columns();
DROP PROCEDURE IF EXISTS add_delivery_fee_currency_columns;
DROP PROCEDURE IF EXISTS add_overseas_finance_p0_column;
DELIMITER $$
CREATE PROCEDURE add_overseas_finance_p0_column(IN p_column VARCHAR(64), IN p_definition TEXT)
@@ -4873,3 +4972,23 @@ SELECT 'PASS' AS migration_status, '海外财务P0费用来源字段已就绪' A
-- 人工货柜节点独立于订舱:货柜尚未关联海运订舱时仍可同步当前节点。
ALTER TABLE ll_container_shipping_gate
MODIFY ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空';
-- 2026-09-12 退货换箱唛、转运换标回写及派车箱唛快照增量。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='return_carton_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货换箱唛来源退货库存箱ID'' AFTER carton_mark'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_carton_mark VARCHAR(255) NULL COMMENT ''退货换箱唛原箱唛快照'' AFTER return_carton_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='new_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN new_carton_mark VARCHAR(255) NULL COMMENT ''退货换箱唛新箱唛'' AFTER source_carton_mark'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'PASS' AS migration_status, '退货换箱唛及转运换标关联字段已就绪' AS message;
-- 2026-09-14 海外尾程按托盘维度作业,无库位托盘允许正常流转及补偿。
USE baiyue_lastlag_warehouse;
ALTER TABLE wh_pallet_operation
MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空',
MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空';
USE baiyue_lastleg;
SELECT 'PASS' AS migration_status, '无库位托盘状态流转结构已就绪' AS message;
@@ -7,6 +7,13 @@ WHERE p.permission_code LIKE 'platform:lastleg:%';
UPDATE iam_permission SET status='INACTIVE', deleted=1
WHERE permission_code LIKE 'platform:lastleg:%';
DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id=role_permission.permission_id
AND permission.tenant_id=role_permission.tenant_id
WHERE permission.permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list');
UPDATE iam_permission SET status='INACTIVE', deleted=1
WHERE permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list');
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)
@@ -14,6 +21,7 @@ VALUES
(5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0),
(5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0),
(5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0),
(5107231,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,0),
(5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0),
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0),
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
@@ -74,7 +82,8 @@ VALUES
(5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0),
(5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0),
(5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path),
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
@@ -262,7 +271,8 @@ FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-warehouse:last-mile-order:edit',
'platform:overseas-warehouse:inbound-order:create'
'platform:overseas-warehouse:inbound-order:create',
'platform:overseas-warehouse:warehouse-transfer:approve'
)
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
@@ -109,6 +109,18 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
'platform:overseas-warehouse:customer-visit:edit')
AND permission.status='ACTIVE' AND permission.deleted=0;
-- 已创建的客户拥有租户级默认管理员角色;权限资源仍引用 tenant_id=0 的公共目录。
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
WHERE role.tenant_id>0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.permission_code IN (
'merchant:warehouse:delivery:list',
'merchant:warehouse:delivery:create')
AND permission.status='ACTIVE' AND permission.deleted=0;
-- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。
DROP TEMPORARY TABLE IF EXISTS tmp_phase5_delivery_role_permission;
CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission (
@@ -296,13 +308,16 @@ BEGIN
'platform:overseas-warehouse:customer-visit:edit')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission ON permission.id=grant_row.permission_id
WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT'
IF EXISTS (SELECT role.id FROM iam_role role
LEFT JOIN iam_role_permission grant_row
ON grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id
LEFT JOIN iam_permission permission ON permission.id=grant_row.permission_id
AND permission.permission_code IN (
'merchant:warehouse:delivery:list',
'merchant:warehouse:delivery:create')) <> 2 THEN
'merchant:warehouse:delivery:create')
WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT'
AND role.status='ACTIVE' AND role.deleted=0
GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 2) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整';
END IF;
END$$
@@ -20,6 +20,7 @@ class OverseasPhase4PermissionSqlContractTest {
"platform:overseas-warehouse:inbound-order:smart-dispatch");
private static final List<String> REQUIRED = List.of(
"platform:overseas-warehouse:last-mile-order:list",
"platform:overseas-warehouse:warehouse-transfer:approve",
"platform:overseas-warehouse:inbound-order:list",
"platform:overseas-warehouse:inbound-order:fba-appointment",
"platform:overseas-warehouse:inbound-order:smart-dispatch",
@@ -41,7 +42,11 @@ class OverseasPhase4PermissionSqlContractTest {
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
assertThat(sql).contains("'lastleg:pda:operate'");
assertPdaPickerGrant(sql);
assertThat(catalog).hasSize(58).noneMatch(code -> code.endsWith(":view"));
assertThat(catalog).hasSize(59).noneMatch(code -> code.endsWith(":view"));
assertThat(sql).contains(
"(5107231,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'");
assertThat(sql).doesNotContain(
"'platform:overseas-warehouse:warehouse-transfer:list','仓储转运审核'");
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
assertThat(sql).doesNotContain(":carton:", ":product:");
assertThat(catalog).contains("platform:overseas-warehouse:delivery:list");
@@ -77,7 +77,14 @@ public class LastlegOrderController {
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String relabelNo,
@Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo));
}
/** Compatibility overload used by non-Spring callers and existing contract tests. */
public ApiResponse<PageResult<LastlegOrderSummary>> list(
AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(platformScope(user, customerId), query));
}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
@@ -22,4 +23,11 @@ public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE")
OutboundOrder lockGlobalByNo(@Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} AND outbound_type='RELABEL_OUTBOUND' " +
"AND outbound_no LIKE CONCAT('%',#{keyword},'%') ORDER BY created_at DESC,id DESC LIMIT 100")
List<OutboundOrder> listRelabelOutbounds(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId,
@Param("keyword") String keyword);
}
@@ -57,6 +57,8 @@ import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -86,6 +88,7 @@ public class DeliveryTransferService {
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryCommandService inventoryCommands;
@Resource private WarehouseTransferPalletMapper warehouseTransferPallets;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@@ -175,10 +178,8 @@ public class DeliveryTransferService {
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
List<String> requestedMarks = request.palletCartonMarks() == null ? List.of() : request.palletCartonMarks().getOrDefault(source.getPalletNo(), List.of());
if (!requestedMarks.isEmpty()) {
if (requestedMarks.size() != Objects.requireNonNullElse(source.getPackageCount(), 0)) throw conflict("Selected carton mark count must equal pallet package count");
item.setCartonMarks(String.join(",", requestedMarks.stream().distinct().toList()));
if (StringUtils.hasText(source.getCartonMarks())) {
item.setCartonMarks(source.getCartonMarks());
} else if (source.getWarehousePalletId() != null) {
String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream()
.map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(","));
@@ -802,7 +803,27 @@ public class DeliveryTransferService {
.stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining(","));
}
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) {
List<Long> palletIds = items.stream().map(DispatchItem::getWarehousePalletId)
.filter(Objects::nonNull).distinct().toList();
List<WarehouseTransferPallet> transferPallets = palletIds.isEmpty() ? List.of()
: warehouseTransferPallets.selectList(new LambdaQueryWrapper<WarehouseTransferPallet>()
.eq(WarehouseTransferPallet::getCustomerId, customerId)
.in(WarehouseTransferPallet::getWarehousePalletId, palletIds));
Set<Long> measuredPalletIds = transferPallets.stream()
.map(WarehouseTransferPallet::getWarehousePalletId).collect(java.util.stream.Collectors.toSet());
BigDecimal measuredWeight = transferPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
List<Long> orderIds = items.stream()
.filter(item -> item.getWarehousePalletId() == null || !measuredPalletIds.contains(item.getWarehousePalletId()))
.map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
if (orderIds.isEmpty()) return measuredWeight;
BigDecimal orderWeight = tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds))
.stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return measuredWeight.add(orderWeight);
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
@@ -38,6 +38,17 @@ public class DispatchController {
return ApiResponse.success(service.update(dispatchNo, request));
}
@DeleteMapping("/{dispatchNo}/items/{itemId}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')")
@Operation(summary = "Remove one unpicked pallet before an outbound order is generated")
public ApiResponse<DispatchResponse> removeItem(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@PathVariable Long itemId) {
requirePlatform(user);
return ApiResponse.success(service.removeItem(dispatchNo, itemId));
}
@DeleteMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
@@ -74,7 +74,9 @@ public class SmartDispatchController {
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.PreArrange request) {
requirePlatform(user);
String scopedCustomer = service.resolveCustomerScopeForPallets(request.planPalletIds());
String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank()
? service.resolveCustomerScopeForPallets(request.planPalletIds())
: service.resolveCustomerScopeForRelabel(request.relabelNo());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
}
@@ -53,7 +53,18 @@ public interface DispatchCandidateMapper {
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.carton_mark=c.carton_mark AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED'))) AS cartonMarks," +
"(SELECT GROUP_CONCAT(DISTINCT ri.fba_carton_mark ORDER BY ri.fba_carton_mark SEPARATOR ',') " +
"FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
@@ -81,6 +92,9 @@ public interface DispatchCandidateMapper {
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " +
"WHERE rli.customer_id=ip.customer_id AND rli.source_inbound_order_id IS NOT NULL " +
"AND rli.source_inbound_order_id=o.id AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
@@ -121,7 +135,18 @@ public interface DispatchCandidateMapper {
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.carton_mark=c.carton_mark AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED'))) AS cartonMarks," +
"(SELECT GROUP_CONCAT(DISTINCT ri.fba_carton_mark ORDER BY ri.fba_carton_mark SEPARATOR ',') " +
"FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
@@ -137,6 +162,11 @@ public interface DispatchCandidateMapper {
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " +
"JOIN ll_inbound_plan_line rpl ON rpl.order_id=rli.source_inbound_order_id " +
"WHERE rli.customer_id=#{customerId} AND rli.source_inbound_order_id IS NOT NULL AND rpl.id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id) " +
"AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" +
@@ -148,20 +178,27 @@ public interface DispatchCandidateMapper {
"SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS containerNo," +
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
"MIN(a.relabel_no) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," +
"MIN(COALESCE(NULLIF(ri.source_inbound_order_no,''),NULLIF(a.customer_order_no,''),a.relabel_no)) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseName," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"SUM(ri.notified_package_count) AS packageCount,NULL AS volumeCbm,'RELABEL' AS sourceStage," +
"GROUP_CONCAT(DISTINCT CONCAT(ri.carton_mark,'->',ri.fba_carton_mark) ORDER BY ri.carton_mark SEPARATOR ',') AS cartonMarks " +
"SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," +
"NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " +
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " +
"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_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"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' " +
"<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},'%') </if>" +
"AND pos.available_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no) " +
"AND pos.hold_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
"AND existing_dispatch.status!='CANCELLED') " +
"GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no" +
"</script>")
List<DispatchPalletCandidate> relabelAvailable(@Param("customerId") String customerId,
@@ -171,4 +208,8 @@ public interface DispatchCandidateMapper {
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'")
int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='WAIT_PRE_DISPATCH',updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='PRE_DISPATCH_CREATED'")
int restoreRelabelPreDispatch(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
}
@@ -10,7 +10,13 @@ import java.util.List;
@Mapper
public interface DispatchDraftMapper extends BaseMapper<DispatchDraft> {
@Select("SELECT * FROM ll_dispatch_draft WHERE operator_id=#{operatorId} AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY updated_at DESC")
@Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " +
"AND (d.expires_at IS NULL OR d.expires_at>CURRENT_TIMESTAMP(3)) AND NOT EXISTS (" +
"SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_outbound_order o ON o.customer_id=l.customer_id " +
"AND o.dispatch_order_id=l.dispatch_order_id WHERE l.draft_token=d.draft_token) AND NOT EXISTS (" +
"SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order x ON x.id=l.dispatch_order_id " +
"AND x.customer_id=l.customer_id WHERE l.draft_token=d.draft_token AND x.status!='PENDING_DEPARTURE') " +
"ORDER BY d.updated_at DESC")
List<DispatchDraft> findActiveByOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE")
@@ -15,6 +15,18 @@ public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
List<DispatchItem> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} " +
"AND id=#{itemId} LIMIT 1 FOR UPDATE")
DispatchItem lockById(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId,
@Param("itemId") Long itemId);
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} " +
"AND plan_pallet_id=#{planPalletId} LIMIT 1 FOR UPDATE")
DispatchItem lockByPlanPallet(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId,
@Param("planPalletId") Long planPalletId);
@Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " +
"JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " +
"SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " +
@@ -17,6 +17,11 @@ public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock>
List<DispatchPalletLock> findActiveByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " +
"ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id AND d.status='PENDING_DEPARTURE' " +
"WHERE l.draft_token=#{draftToken} ORDER BY l.updated_at DESC,l.id DESC LIMIT 1")
Long findDispatchOrderIdByDraft(@Param("draftToken") String draftToken);
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@@ -29,7 +29,8 @@ public record DispatchPalletCandidate(
Integer packageCount,
BigDecimal volumeCbm,
String sourceStage,
String cartonMarks
String cartonMarks,
String relabelCartonMarks
) {
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
@@ -40,7 +41,7 @@ public record DispatchPalletCandidate(
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
sourceStage, null);
sourceStage, null, null);
}
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
@@ -50,7 +51,7 @@ public record DispatchPalletCandidate(
BigDecimal volumeCbm, String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null);
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null);
}
public String sourcePalletKey() {
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
@@ -11,8 +11,10 @@ import java.util.List;
@Mapper
public interface DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOrder> {
@Select("SELECT r.* FROM ll_inbound_stock_receipt r " +
"WHERE r.customer_id=#{customerId} AND r.inventory_position_id=#{positionId} " +
@Select("SELECT DISTINCT r.* FROM ll_inventory_position current_position " +
"JOIN ll_inventory_position receipt_position ON receipt_position.balance_id=current_position.balance_id " +
"JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=receipt_position.id " +
"WHERE current_position.id=#{positionId} AND r.customer_id=#{customerId} " +
"AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " +
"WHERE a.customer_id=r.customer_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id")
List<InboundStockReceipt> availableReceipts(@Param("customerId") String customerId,
@@ -15,8 +15,11 @@ import com.baiyue.lastleg.dispatch.pda.entity.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -41,6 +44,8 @@ public class DispatchPdaWorkflowService {
@Resource private DispatchPalletLockMapper palletLocks;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryCommandService inventory;
@Resource private RelabelApplicationService relabelApplications;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@@ -111,6 +116,11 @@ public class DispatchPdaWorkflowService {
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) {
inventory.release(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), item.getSourceBusinessNo(), order.getDispatchNo(), null,
keyBase + ":RELABEL_RELEASE", "换标排车拣货前释放冻结库存"));
}
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
@@ -125,7 +135,8 @@ public class DispatchPdaWorkflowService {
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
pickAttribution.attribute(customerId, pick);
pickAttribution.attribute(customerId, pick,
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null);
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
@@ -216,6 +227,16 @@ public class DispatchPdaWorkflowService {
long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum();
LocalDateTime now = LocalDateTime.now();
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
for (DispatchItem item : dispatchItems) {
if (item.getOrderId() != null) warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now);
if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) {
warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now);
}
}
dispatchItems.stream().filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(Objects::nonNull).distinct().forEach(relabelNo ->
relabelApplications.changeStatus(customerId, relabelNo, operatorId,
new RelabelRequests.ChangeStatus("COMPLETED", "换标排车PDA发车完成")));
outbound.setStatus("OUTBOUNDED");
outbound.setUpdatedAt(now);
outboundOrders.updateById(outbound);
@@ -236,11 +257,13 @@ public class DispatchPdaWorkflowService {
for (DispatchItem item : source) {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages())
+ (relabel ? n(p.getHoldPackages()) : 0),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
}
OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId());
@@ -27,6 +27,15 @@ public class DispatchPickOrderAttributionService {
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick) {
return attribute(customerId, pick, null);
}
/**
* Relabel inventory created before receipt-detail backfill may have no
* ll_inbound_stock_receipt row. Its dispatch item still carries the
* authoritative tail-leg order, so use that order only for relabel picks.
*/
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) {
long remainingPackages = value(pick.getPackages());
long totalPackages = remainingPackages;
long totalQuantity = value(pick.getProductQuantity());
@@ -58,6 +67,10 @@ public class DispatchPickOrderAttributionService {
allocatedPackages = nextPackages;
allocatedQuantity = nextQuantity;
}
if (remainingPackages != 0 && fallbackOrderId != null && fallbackOrderId > 0) {
pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum);
remainingPackages = 0;
}
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) {
@@ -25,6 +25,7 @@ import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -37,7 +38,9 @@ import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -58,6 +61,7 @@ public class DispatchService {
@Resource private DispatchPdaTaskMapper pdaTasks;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -70,6 +74,12 @@ public class DispatchService {
"Selected pallets");
}
public String resolveCustomerScopeForRelabel(String relabelNo) {
List<DispatchPalletCandidate> values = candidates.relabelAvailable(null, null, trimToNull(relabelNo));
return requireSingleCustomerScope(values.stream().map(DispatchPalletCandidate::customerId)
.filter(StringUtils::hasText).distinct().toList(), "Selected relabel order");
}
public List<DispatchPalletCandidate> candidates(String customerId, List<Long> planIds, String orderNo,
String fbaWarehouseCode, String sourceStage, String draftToken) {
validateSourceStage(sourceStage);
@@ -193,6 +203,15 @@ public class DispatchService {
DispatchPalletLock active = activeLock(customerId, candidate);
if (active == null) return;
requireDraftOwner(active, operatorId, request.draftToken());
if (active.getDispatchOrderId() != null) {
DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId());
if (item != null) removeLockedItem(dispatch, item, List.of(active));
else releaseLock(active, LocalDateTime.now());
return;
}
releaseLock(active, LocalDateTime.now());
}
@@ -201,7 +220,8 @@ public class DispatchService {
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getDraftToken, draftToken)
.eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId))
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
.eq(DispatchPalletLock::getStatus, "ACTIVE")
.isNull(DispatchPalletLock::getDispatchOrderId));
LocalDateTime now = LocalDateTime.now();
activeLocks.forEach(lock -> releaseLock(lock, now));
draftContainers.delete(new LambdaQueryWrapper<DispatchDraftContainer>().eq(DispatchDraftContainer::getDraftToken, draftToken));
@@ -264,35 +284,60 @@ public class DispatchService {
}
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId()));
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraft(draftToken);
DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId);
boolean refreshing = dispatch != null;
if (refreshing) {
requirePalletEditingAllowed(dispatch);
} else {
dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId()));
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setStatus(PENDING_DEPARTURE);
dispatch.setPickedPackageCount(0);
dispatch.setShippedPackageCount(0);
dispatch.setCreatedBy(trimToNull(operatorId));
dispatch.setCreatedAt(now);
}
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected));
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setStatus(PENDING_DEPARTURE);
dispatch.setItemCount(selected.size());
dispatch.setPalletCount(selected.size());
dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
dispatch.setPickedPackageCount(0);
dispatch.setShippedPackageCount(0);
dispatch.setCreatedBy(trimToNull(operatorId));
dispatch.setCreatedAt(now);
dispatch.setUpdatedAt(now);
orders.insert(dispatch);
if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch);
List<DispatchItem> previousItems = items.findByDispatchOrder(customerId, dispatch.getId());
Set<Long> selectedPalletIds = selected.stream().map(DispatchPalletCandidate::planPalletId)
.collect(Collectors.toSet());
if (refreshing) {
List<DispatchPalletLock> previousLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
previousItems.stream().filter(item -> item.getPlanPalletId() != null
&& !selectedPalletIds.contains(item.getPlanPalletId()))
.forEach(item -> {
previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()))
.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId());
});
}
Set<Long> existingPalletIds = previousItems.stream()
.filter(item -> item.getPlanPalletId() == null || selectedPalletIds.contains(item.getPlanPalletId()))
.map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).collect(Collectors.toSet());
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock lock = activeLock(customerId, candidate);
if (draftToken != null) {
requireDraftOwner(lock, operatorId, draftToken);
lock.setDispatchOrderId(dispatch.getId());
lock.setDraftContainerId(null);
lock.setDraftToken(null);
lock.setExpiresAt(null);
lock.setUpdatedAt(now);
locks.updateById(lock);
@@ -318,6 +363,7 @@ public class DispatchService {
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
}
}
if (existingPalletIds.contains(candidate.planPalletId())) continue;
DispatchItem item = new DispatchItem();
item.setId(IdWorker.getId());
item.setDispatchOrderId(dispatch.getId());
@@ -339,27 +385,27 @@ public class DispatchService {
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
item.setSourceStage(candidate.sourceStage());
item.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks()));
item.setCreatedAt(now);
items.insert(item);
}
DispatchPdaTask pdaTask = new DispatchPdaTask();
pdaTask.setId(IdWorker.getId());
pdaTask.setCustomerId(customerId);
pdaTask.setDispatchOrderId(dispatch.getId());
pdaTask.setTaskNo("DPDA" + pdaTask.getId());
pdaTask.setStatus("PDA_PENDING");
pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId());
pdaTask.setPushedAt(now);
pdaTask.setCreatedAt(now);
pdaTask.setUpdatedAt(now);
pdaTasks.insert(pdaTask);
if (!refreshing) {
DispatchPdaTask pdaTask = new DispatchPdaTask();
pdaTask.setId(IdWorker.getId());
pdaTask.setCustomerId(customerId);
pdaTask.setDispatchOrderId(dispatch.getId());
pdaTask.setTaskNo("DPDA" + pdaTask.getId());
pdaTask.setStatus("PDA_PENDING");
pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId());
pdaTask.setPushedAt(now);
pdaTask.setCreatedAt(now);
pdaTask.setUpdatedAt(now);
pdaTasks.insert(pdaTask);
}
if (draftToken != null) {
draftContainers.delete(new LambdaQueryWrapper<DispatchDraftContainer>()
.eq(DispatchDraftContainer::getDraftToken, draftToken));
DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId));
if (draft != null) {
draft.setStatus("RELEASED");
draft.setReleasedAt(now);
draft.setExpiresAt(null);
draft.setUpdatedAt(now);
drafts.updateById(draft);
}
@@ -379,7 +425,7 @@ public class DispatchService {
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(BigDecimal.ZERO); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.cartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
@@ -467,6 +513,50 @@ public class DispatchService {
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
public DispatchResponse removeItem(String dispatchNo, Long itemId) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId);
if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found");
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId())
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
List<DispatchPalletLock> matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId())
|| (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId())))
.toList();
return removeLockedItem(dispatch, item, matchingLocks);
}
private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item,
List<DispatchPalletLock> matchingLocks) {
LocalDateTime now = LocalDateTime.now();
matchingLocks.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId());
List<DispatchItem> remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
dispatch.setItemCount(remaining.size());
dispatch.setPalletCount(remaining.size());
dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum());
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
return response(dispatch, remaining);
}
private void requirePalletEditingAllowed(DispatchOrder dispatch) {
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets");
}
if (outboundOrders.findByDispatch(dispatch.getCustomerId(), dispatch.getId()) != null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘");
}
}
@Transactional
public void cancel(String dispatchNo) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
@@ -480,6 +570,10 @@ public class DispatchService {
dispatch.setStatus(CANCELLED);
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
items.findByDispatchOrder(customerId, dispatch.getId()).stream()
.filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct()
.forEach(relabelNo -> candidates.restoreRelabelPreDispatch(customerId, relabelNo));
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
@@ -577,6 +671,14 @@ public class DispatchService {
return StringUtils.hasText(value) ? value.trim() : null;
}
private static String mergeCartonMarks(String regularMarks, String relabelMarks) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (String source : List.of(Objects.toString(regularMarks, ""), Objects.toString(relabelMarks, ""))) {
Arrays.stream(source.split(",")).map(String::trim).filter(StringUtils::hasText).forEach(values::add);
}
return values.isEmpty() ? null : String.join(",", values);
}
private static void validateSourceStage(String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
@@ -47,6 +47,8 @@ public record LastlegOrderSummary(
LocalDateTime actualArrivalAt,
LocalDateTime estimatedWarehouseAt,
String inboundOrderNo,
@Schema(description = "排车状态", allowableValues = {"SCHEDULED", "UNSCHEDULED"})
String dispatchStatus,
String relabelOrderNo,
String relabelStatus,
String palletMergeFlag,
@@ -17,6 +17,15 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " +
"OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))";
@Select("<script>SELECT o.id AS orderId,CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di " +
"JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND d.customer_id=di.customer_id " +
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED') " +
"THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END AS dispatchStatus FROM ll_order o WHERE o.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<DispatchStatusRow> findDispatchStatuses(@Param("orderIds") List<Long> orderIds);
record DispatchStatusRow(Long orderId, String dispatchStatus) { }
String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " +
"ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id&lt;&gt;'' " +
@@ -28,6 +28,13 @@ public class PdaPalletOperationController {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-pallet-label")
public ApiResponse<PalletOperationResponses.PrintablePallet> createRelabelPallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.NewRelabelPallet request) {
return ApiResponse.success(service.createRelabelPallet(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/assemble")
public ApiResponse<PalletOperationResponses.Operation> assemble(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -13,6 +13,8 @@ public final class PalletOperationRequests {
private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
@PositiveOrZero int expectedVersion) { }
public record MoveLine(@Positive long positionId, @Positive long packages,
@@ -11,8 +11,10 @@ public final class PalletOperationResponses {
long availablePackages, long holdPackages, long allocatedPackages,
long pickedPackages, long receivingPackages, long positionVersion) { }
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
long locationId, String locationCode, String destinationWarehouseCode,
Long locationId, String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
public record PrintablePallet(long palletId, String palletCode, int palletVersion,
String relabelNo, String printableHtml) { }
public record Operation(long operationId, String operationType, String idempotencyKey,
String status, boolean replayed, LocalDateTime completedAt,
List<PalletContent> pallets) {
@@ -28,6 +28,21 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
restClientProperties.getPalletPhysical());
}
@Override
public PalletReference ensureRelabelPallet(long warehouseId, String businessId) {
Map<String, Object> body = Map.of("warehouseId", warehouseId, "businessId", businessId);
ResolveEnvelope response = client.post().uri("/internal/v1/warehouse-pallets/ensure-relabel")
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw palletLookupFailure(result.getStatusCode());
}).body(ResolveEnvelope.class);
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return response.data();
}
@Override
public PalletReference resolve(String palletCode) {
ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode)
@@ -76,11 +91,12 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
}
@Override
public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, Long targetLocationId,
String operationKey, String reason) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version());
body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId);
body.put("targetStatus", targetStatus);
if (targetLocationId != null) body.put("targetLocationId", targetLocationId);
body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason);
TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId())
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
@@ -1,16 +1,17 @@
package com.baiyue.lastleg.palletops.gateway;
public interface PalletPhysicalGateway {
PalletReference ensureRelabelPallet(long warehouseId, String businessId);
PalletReference resolve(String palletCode);
PalletReference resolve(long palletId);
LocationReference resolveLocation(long warehouseId, long locationId);
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, Long targetLocationId,
String operationKey, String reason);
void compensate(long warehouseId, Transition transition);
record PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
record PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
public PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
public PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
String status, String fbaWarehouseCode, int version) {
this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode,
java.math.BigDecimal.ZERO, version);
@@ -18,6 +19,6 @@ public interface PalletPhysicalGateway {
}
record LocationReference(long locationId, String locationCode, String locationType, String status) { }
record Transition(String operationKey, long palletId, String palletCode, long warehouseId,
String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId,
String beforeStatus, String afterStatus, Long beforeLocationId, Long afterLocationId,
int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { }
}
@@ -30,6 +30,7 @@ import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@@ -77,6 +78,30 @@ public class PdaPalletOperationService {
return content(identity.overseasWarehouseId(), pallet);
}
public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity,
PalletOperationRequests.NewRelabelPallet request) {
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认,不能生成新托盘");
}
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
String key = required(request.idempotencyKey(), "幂等键不能为空");
PalletPhysicalGateway.PalletReference pallet = physical.ensureRelabelPallet(identity.overseasWarehouseId(),
relabelNo + ":" + key);
String code = HtmlUtils.htmlEscape(pallet.palletCode());
String order = HtmlUtils.htmlEscape(relabelNo);
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code
+ "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}"
+ "h1{font-size:24px;margin:8mm 0 4mm;letter-spacing:0}.code{font-size:18px;font-weight:700;word-break:break-all}"
+ ".ref{font-size:12px;margin-top:6mm}</style></head><body><h1>托盘标签</h1><div class=\"code\">"
+ code + "</div><div class=\"ref\">换标单:" + order + "</div></body></html>";
return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(),
relabelNo, html);
}
public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Assemble request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_ASSEMBLE",
@@ -175,8 +200,10 @@ public class PdaPalletOperationService {
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(),
new RelabelMutation(application, itemsByPosition));
String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
? "" : ":" + request.reason().trim());
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
request.reason(), relabelPlan);
reason, relabelPlan);
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
@@ -563,9 +590,10 @@ public class PdaPalletOperationService {
.digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String locationCode(long warehouseId, long locationId) {
private String locationCode(long warehouseId, Long locationId) {
if (locationId == null) return null;
PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId);
if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) {
if (location == null || location.locationId() != locationId.longValue() || "INACTIVE".equals(location.status())) {
throw conflict("托盘库位不存在或已停用");
}
return location.locationCode();
@@ -622,10 +650,10 @@ public class PdaPalletOperationService {
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
long targetPalletId, String targetPalletCode, long targetLocationId,
long targetPalletId, String targetPalletCode, Long targetLocationId,
String targetLocationCode, InventoryBucket bucket) { }
private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus,
long targetLocationId) { }
Long targetLocationId) { }
private record Plan(Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, List<PalletChange> changes,
RelabelMutation relabelMutation) { }
@@ -30,6 +30,13 @@ public class MerchantRelabelApplicationController {
@RequestParam(required = false) String inboundCode) {
return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode));
}
@GetMapping("/historical-orders")
public ApiResponse<List<RelabelResponses.HistoricalOrder>> historicalOrders(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String operationWarehouseId,
@RequestParam(required = false, defaultValue = "") String keyword) {
return ApiResponse.success(service.historicalOrders(customer(user), operationWarehouseId, keyword));
}
@PostMapping
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@@ -61,6 +61,26 @@ public class RelabelApplicationController {
return ApiResponse.success(service.changeStatus(null, relabelNo, operator(user), request));
}
@PostMapping("/{relabelNo}/wallet-freeze/retry")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
@Operation(summary = "Retry a failed relabel wallet freeze", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS)
public ApiResponse<RelabelResponses.Detail> retryWalletFreeze(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.WalletFreezeRetry request) {
requirePlatform(user);
return ApiResponse.success(service.retryWalletFreeze(relabelNo, operator(user), request));
}
@PostMapping("/{relabelNo}/wallet-settlement/retry")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
@Operation(summary = "Retry a failed relabel wallet settlement", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS)
public ApiResponse<RelabelResponses.Detail> retryWalletSettlement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.WalletFreezeRetry request) {
requirePlatform(user);
return ApiResponse.success(service.retryWalletSettlement(relabelNo, operator(user), request));
}
@PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
public ApiResponse<RelabelResponses.Detail> uploadFile(@AuthenticationPrincipal AuthenticatedUser user,
@@ -117,4 +117,7 @@ public final class RelabelRequests {
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record WalletFreezeRetry(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
}
@@ -35,6 +35,9 @@ public final class RelabelResponses {
String overseasWarehouseName, String destinationFbaWarehouseCode,
String destinationFbaWarehouseName, String skuCode, String productNameZh,
long availablePackages, long version, List<InventoryPosition> positions) {}
public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo,
String relabelOrderNo, String orderType, String status, Integer packageCount,
LocalDateTime createdAt) {}
public record TailOrder(Long id, String orderNo, String overseasWarehouseId, int packageCount,
String status) {}
public record History(String fromStatus, String toStatus, String operatorId,
@@ -43,4 +43,7 @@ public class RelabelItem {
private String sourceWarehouseCode;
private String sourceWarehouseName;
private LocalDateTime createdAt;
private Long returnCartonId;
private String sourceCartonMark;
private String newCartonMark;
}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication> {
@@ -25,4 +26,11 @@ public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication>
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockByOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
@Update("UPDATE ll_relabel_application SET wallet_status=#{status}, wallet_error_code=#{errorCode}, "
+ "wallet_error_message=#{errorMessage}, wallet_command_event_id=COALESCE(#{eventId},wallet_command_event_id), "
+ "updated_at=NOW(3) WHERE id=#{id}")
int updateWalletState(@Param("id") Long id, @Param("status") String status,
@Param("errorCode") String errorCode, @Param("errorMessage") String errorMessage,
@Param("eventId") String eventId);
}
@@ -18,6 +18,8 @@ import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
@@ -60,6 +62,7 @@ public class PdaRelabelService {
@Resource private RelabelItemMapper items;
@Resource private PdaRelabelTaskMapper tasks;
@Resource private PdaRelabelUnitFactMapper unitFacts;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private PdaRelabelOperationMapper operations;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private RelabelApplicationService applicationService;
@@ -296,6 +299,21 @@ public class PdaRelabelService {
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); }
catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); }
if ("RELABELLED".equals(resultCode)) {
item.setNewCartonMark(resolvedNewLabel);
item.setFbaCartonMark(resolvedNewLabel);
items.updateById(item);
if (item.getReturnCartonId() != null && returnCartons != null) {
ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId());
if (carton != null && application.getCustomerId().equals(carton.getCustomerId())) {
carton.setNewCartonMark(resolvedNewLabel);
carton.setFbaCartonMark(resolvedNewLabel);
carton.setRelabelStatus(PROCESSING);
carton.setUpdatedAt(LocalDateTime.now());
returnCartons.updateById(carton);
}
}
}
if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1);
if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1);
if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
@@ -38,6 +38,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.wallet.WalletResultHandler;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -89,6 +91,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private OutboundPalletItemMapper outboundItems;
@Resource private OutboundPdaTaskMapper outboundTasks;
@Resource private OverseasFeeSourceService feeSources;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Transactional(readOnly = true)
public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); }
@@ -233,6 +236,32 @@ public class RelabelApplicationService implements WalletResultHandler {
balance.getProductNameZh(), available, value(balance.getVersion()), positions);
}).filter(candidate -> candidate.availablePackages() > 0).toList();
}
@Transactional(readOnly = true)
public List<RelabelResponses.HistoricalOrder> historicalOrders(String customerId, String operationWarehouseId,
String keyword) {
if (!StringUtils.hasText(operationWarehouseId)) throw badRequest("Operation warehouse is required");
String q = StringUtils.hasText(keyword) ? keyword.trim() : "";
List<LastlegOrder> orders = this.orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOverseasWarehouseId, operationWarehouseId.trim())
.and(w -> w.like(StringUtils.hasText(q), LastlegOrder::getOrderNo, q)
.or().like(StringUtils.hasText(q), LastlegOrder::getSourceOrderNo, q)
.or().like(StringUtils.hasText(q), LastlegOrder::getRelabelOrderNo, q)
.or().like(StringUtils.hasText(q), LastlegOrder::getOutboundOrderNo, q))
.orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)
.last("LIMIT 100"));
Map<String, RelabelResponses.HistoricalOrder> result = new java.util.LinkedHashMap<>();
orders.forEach(order -> result.put("ORDER:" + order.getId(), new RelabelResponses.HistoricalOrder(order.getId(),
order.getOrderNo(), order.getSourceOrderNo(), order.getOutboundOrderNo(), order.getRelabelOrderNo(),
order.getOrderType(), order.getCurrentStatus(), order.getPackageCount(), order.getCreatedAt())));
for (OutboundOrder outbound : outboundOrders.listRelabelOutbounds(customerId, operationWarehouseId.trim(), q)) {
result.putIfAbsent("OUTBOUND:" + outbound.getId(), new RelabelResponses.HistoricalOrder(null,
outbound.getOutboundNo(), null, outbound.getOutboundNo(), null, outbound.getOutboundType(),
outbound.getStatus(), outbound.getPackageCount(), outbound.getCreatedAt()));
}
return result.values().stream().limit(100).toList();
}
@Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
@@ -411,7 +440,12 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
applications.updateById(application);
} else if ("COMPLETED".equals(next)) {
if (!StringUtils.hasText(application.getOutboundOrderNo())) releaseInventory(customerId, operatorId, application);
boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus());
application.setFulfillmentStatus("OUTBOUNDED");
if (!StringUtils.hasText(application.getOutboundOrderNo())
&& !preDispatched) {
releaseInventory(customerId, operatorId, application);
}
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
@@ -457,6 +491,62 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo);
}
@Transactional
public RelabelResponses.Detail retryWalletFreeze(String relabelNo, String operatorId,
RelabelRequests.WalletFreezeRetry request) {
RelabelApplication application = applications.lockAnyByRelabelNo(relabelNo);
if (application == null) throw notFound("Relabel application not found");
String customerId = application.getCustomerId();
if ("FROZEN".equals(application.getWalletStatus()) || "FREEZE_PENDING".equals(application.getWalletStatus())) {
return detail(customerId, relabelNo);
}
if (!PENDING_PROCESSING.equals(application.getStatus()) || !"FREEZE_FAILED".equals(application.getWalletStatus())) {
throw conflict("Only a pending relabel application with failed funds freeze can be retried");
}
String retryKey = request.idempotencyKey().trim();
long walletVersion = 100L + Long.parseLong(hash(retryKey).substring(0, 15), 16);
int holdIndex = 0;
for (RelabelItem item : itemList(application.getId())) {
if (item.getSourceInventoryPositionId() == null
|| Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) <= 0) continue;
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo,
null, "RELABEL:" + relabelNo, "RELABEL:" + application.getId() + ":RETRY_HOLD:"
+ walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存"));
}
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, application.getChargeAmount(), trim(request.remark()), walletVersion);
application.setWalletStatus("FREEZE_PENDING");
application.setWalletErrorCode(null); application.setWalletErrorMessage(null);
application.setWalletCommandEventId(walletEventId); application.setUpdatedAt(LocalDateTime.now());
applications.updateWalletState(application.getId(), application.getWalletStatus(), null, null, walletEventId);
insertHistory(customerId, application.getId(), application.getStatus(), application.getStatus(), operatorId,
"Wallet freeze retry requested", LocalDateTime.now());
return detail(customerId, relabelNo);
}
@Transactional
public RelabelResponses.Detail retryWalletSettlement(String relabelNo, String operatorId,
RelabelRequests.WalletFreezeRetry request) {
RelabelApplication application = applications.lockAnyByRelabelNo(relabelNo);
if (application == null) throw notFound("Relabel application not found");
String customerId = application.getCustomerId();
if ("SETTLED".equals(application.getWalletStatus()) || "SETTLE_PENDING".equals(application.getWalletStatus())) {
return detail(customerId, relabelNo);
}
if (!"COMPLETED".equals(application.getStatus()) || !"SETTLE_FAILED".equals(application.getWalletStatus())) {
throw conflict("Only a completed relabel application with failed funds settlement can be retried");
}
String retryKey = request.idempotencyKey().trim();
long walletVersion = 200L + Long.parseLong(hash(retryKey).substring(0, 15), 16);
String walletEventId = walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, null, trim(request.remark()), walletVersion);
applications.updateWalletState(application.getId(), "SETTLE_PENDING", null, null, walletEventId);
insertHistory(customerId, application.getId(), application.getStatus(), application.getStatus(), operatorId,
"Wallet settlement retry requested", LocalDateTime.now());
return detail(customerId, relabelNo);
}
@Transactional
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
RelabelRequests.CustomerConfirm request) {
@@ -492,12 +582,37 @@ public class RelabelApplicationService implements WalletResultHandler {
order.setUpdatedAt(now);
orders.updateById(order);
if (!Objects.equals(orderBefore, order.getCurrentStatus())) {
insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(),
insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(),
request.remark(), now);
}
}
insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId,
request.remark(), now);
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
if (warehouseTransfers != null) {
WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) {
transfer.setStatus("READY_FOR_DISPATCH");
transfer.setRelabelCompletedAt(now);
transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer);
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
String transferOrderBefore = transferOrder.getCurrentStatus();
transferOrder.setCurrentStatus("READY_FOR_DISPATCH");
transferOrder.setRelabelStatus(READY_FOR_OUTBOUND);
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:"
+ READY_FOR_OUTBOUND);
transferOrder.setUpdatedAt(now);
orders.updateById(transferOrder);
if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) {
insertOrderHistory(customerId, transferOrder.getId(), transferOrderBefore,
transferOrder.getCurrentStatus(), "Warehouse transfer relabel completed", now);
}
}
}
}
return detail(customerId, relabelNo);
}
@@ -622,7 +737,8 @@ public class RelabelApplicationService implements WalletResultHandler {
releaseInventory(value.getCustomerId(), "WALLET_CALLBACK", value);
}
}
value.setUpdatedAt(LocalDateTime.now()); applications.updateById(value);
applications.updateWalletState(value.getId(), value.getWalletStatus(), value.getWalletErrorCode(),
value.getWalletErrorMessage(), null);
}
private RelabelResponses.Item item(RelabelItem value) {
@@ -23,6 +23,15 @@ public class MerchantReturnOrderController {
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{returnNo}") public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); }
@GetMapping("/{returnNo}/relabel-candidates") public ApiResponse<java.util.List<ReturnOrderResponses.RelabelCandidate>> relabelCandidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) {
return ApiResponse.success(service.relabelCandidates(customer(user), returnNo));
}
@PostMapping("/{returnNo}/relabel") public ApiResponse<ReturnOrderResponses.RelabelCreated> createRelabel(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo,
@Valid @RequestBody ReturnOrderRequests.RelabelCreate request) {
return ApiResponse.success(service.createRelabel(customer(user), operator(user), returnNo, request));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
@@ -27,4 +27,8 @@ public final class ReturnOrderRequests {
String trackingNo, String skuCode, String status) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record RelabelCreate(@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 128) String contactName, @Size(max = 64) String contactPhone,
@Size(max = 32) String deliveryMethod, @Size(max = 1000) String remark) {}
}
@@ -14,4 +14,8 @@ public final class ReturnOrderResponses {
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts) { }
public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt) { }
public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status,
long totalQuantity, List<Item> items, String relabelStatus) { }
public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo,
List<String> cartonMarks, String status) { }
}
@@ -36,4 +36,5 @@ public class ReturnInventoryCarton {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime mergedAt;
private String newCartonMark;
}
@@ -18,6 +18,10 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper;
import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -52,6 +56,8 @@ public class ReturnInventoryService {
@Resource private LastlegOrderNoGenerator numbers;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper orders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper receiptMapper;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderDetailMapper lastlegOrderDetails;
@Transactional
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
@@ -230,9 +236,13 @@ public class ReturnInventoryService {
if (selectedOrders.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()))) throw conflict("只能合并已完成签收的退货单");
if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱");
List<ReturnInventoryCarton> sources = new ArrayList<>();
Map<Long, ReturnOrder> sourceOwners = new LinkedHashMap<>();
for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton != null && AVAILABLE.equals(carton.getStatus())) sources.add(cartons.lockMark(carton.getCartonMark()));
if (carton != null && AVAILABLE.equals(carton.getStatus())) {
ReturnInventoryCarton locked = cartons.lockMark(carton.getCartonMark());
sources.add(locked); sourceOwners.put(locked.getId(), order);
}
}
if (sources.isEmpty()) throw conflict("所选退货单没有可合箱的库存箱");
Map<String, Long> requested = new LinkedHashMap<>();
@@ -245,6 +255,7 @@ public class ReturnInventoryService {
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
LinkedHashSet<String> consumedBatchNos = new LinkedHashSet<>();
LinkedHashSet<String> consumedReturnNos = new LinkedHashSet<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
@@ -256,6 +267,9 @@ public class ReturnInventoryService {
consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱"));
consumedReturnNos.add(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱"));
remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take);
ReturnOrder owner = sourceOwners.get(source.getId());
if (owner != null) attachedByOrder.computeIfAbsent(owner.getId(), ignored -> new LinkedHashMap<>())
.merge(sku, take, Math::addExact);
productNames.putIfAbsent(sku, item.getProductNameZh());
item.setQuantity(item.getQuantity() - take);
if (item.getQuantity() == 0) cartonItems.deleteById(item.getId()); else cartonItems.updateById(item);
@@ -286,10 +300,36 @@ public class ReturnInventoryService {
throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey());
}
}
attachMergedCarton(target, attachedByOrder, selectedOrders, productNames, now);
operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation);
return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target));
}
private void attachMergedCarton(ReturnInventoryCarton target, Map<Long, Map<String, Long>> attachedByOrder,
List<ReturnOrder> selectedOrders, Map<String, String> productNames, LocalDateTime now) {
Map<Long, ReturnOrder> byId = selectedOrders.stream().collect(java.util.stream.Collectors.toMap(ReturnOrder::getId, value -> value));
for (Map.Entry<Long, Map<String, Long>> orderEntry : attachedByOrder.entrySet()) {
ReturnOrder returnOrder = byId.get(orderEntry.getKey());
if (returnOrder == null || returnOrder.getLastlegOrderId() == null) continue;
LastlegOrder tail = lastlegOrders.selectById(returnOrder.getLastlegOrderId());
if (tail == null || !returnOrder.getCustomerId().equals(tail.getCustomerId())) continue;
long quantity = orderEntry.getValue().values().stream().mapToLong(Long::longValue).sum();
boolean firstSku = true;
for (Map.Entry<String, Long> skuEntry : orderEntry.getValue().entrySet()) {
LastlegOrderDetail detail = new LastlegOrderDetail();
detail.setId(IdWorker.getId()); detail.setCustomerId(returnOrder.getCustomerId()); detail.setOrderId(tail.getId());
detail.setSourceInboundDetailId("RETURN_CARTON:" + target.getId() + ":" + returnOrder.getId() + ":" + skuEntry.getKey());
detail.setSkuCode(skuEntry.getKey()); detail.setProductNameZh(productNames.get(skuEntry.getKey()));
detail.setPackageCount(firstSku ? 1 : 0); detail.setProductQuantity(Math.toIntExact(skuEntry.getValue())); detail.setShippingMark(target.getCartonMark());
detail.setDestinationWarehouseCode(target.getDestinationWarehouseCode()); detail.setDestinationWarehouseName(target.getDestinationWarehouseName());
lastlegOrderDetails.insert(detail); firstSku = false;
}
tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), 1));
tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(quantity)));
tail.setUpdatedAt(now); lastlegOrders.updateById(tail);
}
}
private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求");
if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理");
@@ -21,6 +21,14 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -59,6 +67,10 @@ public class ReturnReceiptService {
@Resource private LastlegOrderNoGenerator numbers;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Transactional
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
@@ -161,6 +173,61 @@ public class ReturnReceiptService {
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews);
}
@Transactional(readOnly = true)
public List<ReturnOrderResponses.RelabelCandidate> relabelCandidates(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
List<ReturnOrderResponses.RelabelCandidate> result = new ArrayList<>();
for (ReturnReceipt receipt : receipts.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton == null || !ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue;
List<ReturnOrderResponses.Item> items = cartonItems.listCarton(carton.getId()).stream()
.map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList();
result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus()));
}
return result;
}
@Transactional
public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo,
ReturnOrderRequests.RelabelCreate request) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
String key = text(request.idempotencyKey());
RelabelApplication replay = relabelApplications.findKey(customerId, key);
if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), order.getReturnNo(), request.cartonMarks(), replay.getStatus());
List<ReturnInventoryCarton> selected = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String mark : request.cartonMarks()) {
String normalized = text(mark);
if (!seen.add(normalized.toUpperCase(java.util.Locale.ROOT))) throw badRequest("箱唛不能重复");
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, normalized);
if (carton == null || !order.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw notFound("退货箱不存在: " + normalized);
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱不可申请换标: " + normalized);
if (carton.getSourceReturnNo() == null || !java.util.Arrays.asList(carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())) throw conflict("退货箱不属于当前退货单: " + normalized);
selected.add(cartons.lockMark(normalized));
}
long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now();
String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId);
LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId()));
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo);
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus("RELABEL:RETURN_CARTON"); tail.setRelabelStatus("RETURN_CARTON"); tail.setPackageCount(selected.size());
tail.setProductQuantity(Math.toIntExact(selected.stream().mapToLong(c -> value(c.getTotalQuantity())).sum())); tail.setCreatedAt(now); tail.setUpdatedAt(now); lastlegOrders.insert(tail);
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setContactName(text(request.contactName())); app.setContactPhone(text(request.contactPhone())); app.setDeliveryMethod(text(request.deliveryMethod()) == null ? "TRUCK" : text(request.deliveryMethod()));
app.setFulfillmentStatus("WAIT_RELABEL"); app.setRemark(text(request.remark())); app.setStatus("PENDING_PROCESSING"); app.setItemCount(selected.size()); app.setTotalPackageCount(selected.size()); app.setNotifiedPackageCount(selected.size()); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(hash(request)); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
for (ReturnInventoryCarton carton : selected) {
for (ReturnInventoryCartonItem ci : cartonItems.listCarton(carton.getId())) {
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(ci.getSkuCode()); item.setProductNameZh(ci.getProductNameZh()); item.setTotalPackageCount(1); item.setNotifiedPackageCount(1); item.setShipmentNo(order.getReturnNo()); item.setCartonMark(carton.getCartonMark()); item.setFbaCartonMark(carton.getFbaCartonMark()); item.setOperationType("CARTON_LABEL"); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setCreatedAt(now); relabelItems.insert(item);
}
carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton);
}
order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_PROCESSING"); orders.updateById(order);
return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), selected.stream().map(ReturnInventoryCarton::getCartonMark).toList(), app.getStatus());
}
@Transactional(readOnly = true)
public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo());
@@ -5,6 +5,8 @@ public final class OverseasPermissions {
public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list";
public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit";
public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export";
public static final String WAREHOUSE_TRANSFER_APPROVE =
"platform:overseas-warehouse:warehouse-transfer:approve";
public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list";
public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create";
public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit";
@@ -52,6 +52,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class LastlegOrderService {
@@ -79,13 +80,18 @@ public class LastlegOrderService {
@Resource private ContainerShippingGateMapper containerShippingGates;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null);
}
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt));
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt));
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
Map<String, String> containerNodes = containerNodes(page.getRecords());
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null)).toList(),
? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -268,7 +274,7 @@ public class LastlegOrderService {
value.getNodeCode(), value.getNodeAt(), value.getSource()))
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
containerNode(order)), manualNodes, cargo, history.stream()
containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -517,6 +523,10 @@ public class LastlegOrderService {
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
return filter(customerId, query, null);
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
@@ -525,6 +535,7 @@ public class LastlegOrderService {
return new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
.like(StringUtils.hasText(relabelNo), LastlegOrder::getRelabelOrderNo, relabelNo)
.eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode())
@@ -567,8 +578,17 @@ public class LastlegOrderService {
return result;
}
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
List<LastlegOrderMapper.DispatchStatusRow> rows = orderMapper.findDispatchStatuses(
orders.stream().map(LastlegOrder::getId).toList());
if (rows == null) return Map.of();
return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.DispatchStatusRow::orderId,
LastlegOrderMapper.DispatchStatusRow::dispatchStatus));
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String containerNode) {
String containerNode, String dispatchStatus) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
@@ -579,7 +599,7 @@ public class LastlegOrderService {
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
@@ -36,6 +36,13 @@ public class MerchantWarehouseTransferController {
return ApiResponse.success(service.detail(customer(user), transferNo));
}
@PostMapping("/{transferNo}/confirm-measurement")
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.CustomerConfirm request) {
return ApiResponse.success(service.confirmMeasurement(customer(user), transferNo, operator(user), request));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/warehouse-transfers")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaWarehouseTransferController {
@Resource private WarehouseTransferService service;
@GetMapping("/candidates")
public ApiResponse<List<WarehouseTransferResponses.Summary>> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId()));
}
@PostMapping("/{transferNo}/palletize")
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Palletize request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(),
transferNo, request));
}
}
@@ -3,12 +3,14 @@ package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -26,22 +28,31 @@ public class WarehouseTransferController {
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{transferNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<WarehouseTransferResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo) {
return ApiResponse.success(service.detail(scope(user, customerId), transferNo));
}
@GetMapping("/{transferNo}/carton-label")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo) {
return ApiResponse.success(service.cartonLabel(scope(user, customerId), transferNo));
}
@PostMapping("/{transferNo}/review")
@PreAuthorize("hasAuthority('" + OverseasPermissions.WAREHOUSE_TRANSFER_APPROVE + "')")
public ApiResponse<WarehouseTransferResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Review request) {
requireApprovalPermission(user);
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
}
@@ -69,13 +80,6 @@ public class WarehouseTransferController {
return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request));
}
private void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !user.permissions().contains("lastleg:warehouse-transfer:approve")) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer approval permission is required");
}
}
private void requirePermission(AuthenticatedUser user, String permission) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(permission)) {
@@ -23,17 +23,34 @@ public final class WarehouseTransferRequests {
@Size(max = 128) String fbaWarehouseCode,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm,
@NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@NotNull LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
@Size(max = 128) String fbaShipmentNo,
@Size(max = 255) String trackingNo,
@Size(max = 2000) String fbaBoxNos,
@Size(max = 1000) String remark,
boolean needRelabel,
LocalDateTime labelDeadline,
@Size(max = 128) String relabelContactName,
@Size(max = 64) String relabelContactPhone,
@DecimalMin(value = "0.01") BigDecimal relabelChargeAmount,
@Size(max = 32) String relabelDeliveryMethod,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items
) {}
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@Size(max = 1000) List<@Valid RelabelItem> relabelItems
) {
public Create(String inboundCode, List<Long> sourceLastlegOrderIds, String operatingWarehouseId,
String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm,
String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo,
String trackingNo, String fbaBoxNos, String remark, boolean needRelabel,
String idempotencyKey, List<Item> items) {
this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress,
packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo,
fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null);
}
}
public record Review(@NotBlank @Size(max = 32) String decision,
@Size(max = 1000) String remark) {}
@@ -52,6 +69,24 @@ public final class WarehouseTransferRequests {
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Palletize(
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 100) List<@Valid Pallet> pallets
) {}
public record Pallet(
@Size(max = 128) String palletCode,
@Positive int cartonCount,
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg,
@NotEmpty @Size(max = 500) List<@Valid PalletItem> items
) {}
public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {}
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Receipt(
@NotNull Long sourceInventoryPositionId,
@NotBlank @Size(max = 128) String destinationOverseasWarehouseId,
@@ -76,6 +111,17 @@ public final class WarehouseTransferRequests {
@Size(max = 255) String shippingMark
) {}
public record RelabelItem(
@NotBlank @Size(max = 32) String operationType,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks,
@NotBlank @Size(max = 128) String originalSku,
@Size(max = 128) String newSku,
@Positive int quantity,
@Size(max = 255) String fbaCartonMark,
@Size(max = 128) String destinationWarehouseCode,
@Size(max = 1000) String remark
) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String transferNo, String inboundCode, String operatingWarehouseId,
String fbaWarehouseCode, String status, Boolean needRelabel,
@@ -1,12 +1,23 @@
package com.baiyue.lastleg.transfer.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class WarehouseTransferResponses {
private WarehouseTransferResponses() {}
public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {}
public record Carton(String cartonMark, int cartonIndex, int totalCartons, String status) {}
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 Label(String transferNo, List<Carton> cartons, String printableHtml) {}
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo,
Long warehouseLocationId, String warehouseLocationCode, int cartonCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal grossWeightKg, BigDecimal volumeCbm, String status,
List<PalletItem> items, LocalDateTime completedAt) {}
public record RelabelBinding(String operationType, String customerCartonMark, String originalSku,
String newSku, int quantity, String fbaCartonMark,
String destinationWarehouseCode, String remark) {}
public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {}
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
Long palletId, String palletNo, int packageCount, String status,
@@ -19,9 +30,12 @@ public final class WarehouseTransferResponses {
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
String status,
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons, List<Allocation> allocations,
LocalDateTime outboundAt, LocalDateTime createdAt) {}
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
LocalDateTime actualShipAt, LocalDateTime createdAt) {}
}
@@ -15,6 +15,7 @@ public class WarehouseTransferCarton {
private Long transferItemId;
private String customerId;
private String cartonMark;
private String customerCartonMark;
private Integer cartonIndex;
private Integer totalCartons;
private String status;
@@ -22,19 +22,31 @@ public class WarehouseTransferOrder {
private Integer packageCount;
private String deliveryMethod;
private LocalDateTime estimatedArrivalAt;
private LocalDateTime estimatedShipAt;
private String fbaShipmentNo;
private String trackingNo;
private String fbaBoxNos;
private String remark;
private Boolean needRelabel;
private LocalDateTime labelDeadline;
private String relabelContactName;
private String relabelContactPhone;
private java.math.BigDecimal relabelChargeAmount;
private String relabelDeliveryMethod;
private Long relabelApplicationId;
private String relabelNo;
private LocalDateTime relabelCompletedAt;
private String status;
private String allocationIdempotencyKey;
private String allocationRequestHash;
private String outboundIdempotencyKey;
private String outboundRequestHash;
private LocalDateTime outboundAt;
private String palletizeIdempotencyKey;
private String palletizeRequestHash;
private String confirmationIdempotencyKey;
private String confirmationRequestHash;
private LocalDateTime customerConfirmedAt;
private String idempotencyKey;
private String requestHash;
private String createdBy;
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_pallet")
public class WarehouseTransferPallet {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private String customerId;
private Integer sequenceNo;
private Long warehousePalletId;
private String palletNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private Long warehouseResourceLockId;
private Integer cartonCount;
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private BigDecimal grossWeightKg;
private BigDecimal volumeCbm;
private String status;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_pallet_item")
public class WarehouseTransferPalletItem {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private Long transferPalletId;
private Long transferItemId;
private String customerId;
private Integer packageCount;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_relabel_binding")
public class WarehouseTransferRelabelBinding {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private String customerId;
private String operationType;
private String customerCartonMark;
private String originalSku;
private String newSku;
private Integer quantity;
private String fbaCartonMark;
private String destinationWarehouseCode;
private String remark;
private LocalDateTime createdAt;
}
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper public interface WarehouseTransferOrderMapper extends BaseMapper<WarehouseTransferOrder> {
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@@ -11,4 +12,16 @@ import org.apache.ibatis.annotations.Select;
WarehouseTransferOrder findNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id")
java.util.List<WarehouseTransferOrder> pdaCandidates(@Param("warehouseId") String warehouseId);
@Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL")
int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("outboundAt") java.time.LocalDateTime outboundAt);
@Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND outbound_at IS NULL")
int markOutboundByRelabel(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo,
@Param("outboundAt") java.time.LocalDateTime outboundAt);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehouseTransferPalletItemMapper extends BaseMapper<WarehouseTransferPalletItem> {
@Select("SELECT * FROM ll_warehouse_transfer_pallet_item WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY transfer_pallet_id,id")
List<WarehouseTransferPalletItem> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehouseTransferPalletMapper extends BaseMapper<WarehouseTransferPallet> {
@Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY sequence_no")
List<WarehouseTransferPallet> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehouseTransferRelabelBindingMapper extends BaseMapper<WarehouseTransferRelabelBinding> {
@Select("SELECT * FROM ll_warehouse_transfer_relabel_binding WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY id")
List<WarehouseTransferRelabelBinding> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
}
@@ -20,6 +20,20 @@ import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
@@ -29,6 +43,9 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper;
@@ -36,6 +53,9 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMa
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -53,7 +73,12 @@ import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.ArrayList;
import java.util.Comparator;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -68,11 +93,18 @@ public class WarehouseTransferService {
public static final String OUTBOUNDED = "OUTBOUNDED";
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String RECEIVED = "RECEIVED";
public static final String WAIT_PDA_RECEIVING = "WAIT_PDA_RECEIVING";
public static final String WAIT_CUSTOMER_CONFIRMATION = "WAIT_CUSTOMER_CONFIRMATION";
public static final String READY_FOR_DISPATCH = "READY_FOR_DISPATCH";
public static final String WAIT_RELABEL = "WAIT_RELABEL";
@Resource private WarehouseTransferOrderMapper transfers;
@Resource private WarehouseTransferItemMapper transferItems;
@Resource private WarehouseTransferCartonMapper transferCartons;
@Resource private WarehouseTransferSourceOrderMapper sourceOrders;
@Resource private WarehouseTransferPalletMapper transferPallets;
@Resource private WarehouseTransferPalletItemMapper transferPalletItems;
@Resource private WarehouseTransferRelabelBindingMapper relabelBindings;
@Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Resource private WarehouseTransferReceiptMapper transferReceipts;
@Resource private InboundSnapshotMapper inboundSnapshots;
@@ -81,6 +113,13 @@ public class WarehouseTransferService {
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelApplicationService relabelService;
@Resource private WarehouseResourceGateway warehouseResources;
@Resource private InboundPlanMapper inboundPlans;
@Resource private InboundPlanLineMapper inboundPlanLines;
@Resource private InboundPlanPalletMapper inboundPlanPallets;
@Resource private InboundPlanPalletAllocationMapper inboundPlanAllocations;
@Resource private InboundStockReceiptMapper stockReceipts;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private InventoryCommandService inventory;
@Resource private InventoryCommandMapper inventoryCommands;
@@ -93,6 +132,7 @@ public class WarehouseTransferService {
if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count");
}
validateRelabelRequest(request);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key);
@@ -142,10 +182,16 @@ public class WarehouseTransferService {
transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode()));
transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount());
transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt());
transfer.setEstimatedShipAt(request.estimatedShipAt());
transfer.setFbaShipmentNo(trim(request.fbaShipmentNo()));
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null);
transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark()));
transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW);
transfer.setLabelDeadline(request.labelDeadline());
transfer.setRelabelContactName(trim(request.relabelContactName()));
transfer.setRelabelContactPhone(trim(request.relabelContactPhone()));
transfer.setRelabelChargeAmount(request.relabelChargeAmount());
transfer.setRelabelDeliveryMethod(trim(request.relabelDeliveryMethod()));
transfer.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId));
transfer.setCreatedAt(now); transfer.setUpdatedAt(now);
transfers.insert(transfer);
@@ -164,14 +210,17 @@ public class WarehouseTransferService {
sourceOrders.insert(link);
}
if (request.needRelabel() && request.relabelItems() != null) insertRelabelBindings(customerId, transferId, request.relabelItems(), now);
int cartonIndex = 0;
for (int index = 0; index < request.items().size(); index++) {
WarehouseTransferRequests.Item source = request.items().get(index);
long itemId = insertTransferItem(customerId, transferId, source, now);
List<String> customerMarks = splitMarks(source.shippingMark());
for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) {
cartonIndex++;
insertTransferCarton(customerId, transferId, itemId, transferNo,
cartonIndex, request.packageCount(), now);
cartonIndex, request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now);
}
insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now);
}
@@ -213,16 +262,36 @@ public class WarehouseTransferService {
List<WarehouseTransferResponses.Item> items = transferItems.selectList(
new LambdaQueryWrapper<WarehouseTransferItem>().eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId))
.stream().map(value -> new WarehouseTransferResponses.Item(value.getSkuCode(), value.getProductNameZh(),
.stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(),
value.getPackageCount(), value.getShippingMark())).toList();
Map<Long, WarehouseTransferResponses.Item> itemById = items.stream()
.collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity()));
List<WarehouseTransferResponses.SourceOrder> sources = sourceOrders.selectList(
new LambdaQueryWrapper<WarehouseTransferSourceOrder>().eq(WarehouseTransferSourceOrder::getCustomerId, customerId)
.eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId))
.stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(),
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId())
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(),
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(),
value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
List<WarehouseTransferPalletItem> palletItemRows = transferPalletItems == null
? List.of() : transferPalletItems.listOrder(customerId, transfer.getId());
Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream()
.collect(Collectors.groupingBy(WarehouseTransferPalletItem::getTransferPalletId));
List<WarehouseTransferResponses.Pallet> pallets = transferPallets == null ? List.of() : transferPallets.listOrder(customerId, transfer.getId()).stream()
.map(value -> new WarehouseTransferResponses.Pallet(value.getId(), value.getSequenceNo(),
value.getWarehousePalletId(), value.getPalletNo(), value.getWarehouseLocationId(),
value.getWarehouseLocationCode(), value.getCartonCount(), value.getLengthCm(), value.getWidthCm(),
value.getHeightCm(), value.getGrossWeightKg(), value.getVolumeCbm(), value.getStatus(),
palletItemsByPallet.getOrDefault(value.getId(), List.of()).stream().map(row -> {
WarehouseTransferResponses.Item item = itemById.get(row.getTransferItemId());
return new WarehouseTransferResponses.PalletItem(row.getTransferItemId(),
item == null ? null : item.skuCode(), row.getPackageCount());
}).toList(), value.getCompletedAt())).toList();
List<WarehouseTransferResponses.RelabelBinding> bindings = relabelBindings == null ? List.of() : relabelBindings.listOrder(customerId, transfer.getId())
.stream().map(value -> new WarehouseTransferResponses.RelabelBinding(value.getOperationType(),
value.getCustomerCartonMark(), value.getOriginalSku(), value.getNewSku(), value.getQuantity(),
value.getFbaCartonMark(), value.getDestinationWarehouseCode(), value.getRemark())).toList();
Map<Long, WarehouseTransferReceipt> receiptByAllocation = transferReceipts.selectList(
new LambdaQueryWrapper<WarehouseTransferReceipt>().eq(WarehouseTransferReceipt::getCustomerId, customerId)
.eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId()))
@@ -252,9 +321,32 @@ public class WarehouseTransferService {
Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(),
relabelStatus,
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(),
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, cartons, allocations,
transfer.getOutboundAt(), transfer.getCreatedAt());
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(),
transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(),
transfer.getRemark(), sources, items, cartons, pallets, bindings, allocations,
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
}
@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");
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();
StringBuilder labels = new StringBuilder();
for (WarehouseTransferResponses.Carton carton : cartons) {
labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>")
.append(escape(carton.cartonMark())).append("</div><div class=meta>")
.append(carton.cartonIndex()).append(" / ").append(carton.totalCartons()).append(" 转运单号:")
.append(escape(transferNo)).append("</div>");
if (text(carton.customerCartonMark()) != null) labels.append("<div class=customer>客户箱唛:")
.append(escape(carton.customerCartonMark())).append("</div>");
labels.append("</section>");
}
String html = "<!doctype html><html><head><meta charset=utf-8><title>" + escape(transferNo) + "-箱唛</title><style>"
+ "@page{size:100mm 100mm;margin:8mm}body{margin:0;font-family:Arial,'Microsoft YaHei',sans-serif;color:#111}.label{height:84mm;display:flex;flex-direction:column;align-items:center;justify-content:center;page-break-after:always;border:1px solid #222;box-sizing:border-box;padding:8mm}.title{font-size:18px;font-weight:700}.mark{margin:12mm 0 6mm;font-size:30px;font-weight:700;letter-spacing:1px;word-break:break-all;text-align:center}.meta,.customer{font-size:12px;margin-top:3mm}</style></head><body>"
+ labels + "</body></html>";
return new WarehouseTransferResponses.Label(transferNo, cartons, html);
}
@Transactional
@@ -269,15 +361,18 @@ public class WarehouseTransferService {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!PENDING_REVIEW.equals(transfer.getStatus())) {
if (next.equals(transfer.getStatus())) return detail(customerId, transferNo);
if (next.equals(transfer.getStatus()) || APPROVED.equals(next)
&& Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, WAIT_RELABEL)
.contains(transfer.getStatus())) return detail(customerId, transferNo);
throw conflict("Warehouse transfer order was already reviewed");
}
LastlegOrder order = orders.selectById(transfer.getLastlegOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
LocalDateTime now = LocalDateTime.now();
String before = order.getCurrentStatus();
transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer);
boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode());
String transferStatus = APPROVED.equals(next) && incomingTransit ? WAIT_PDA_RECEIVING : next;
transfer.setStatus(transferStatus); transfer.setUpdatedAt(now); transfers.updateById(transfer);
String orderStatus = APPROVED.equals(next)
? (incomingTransit
? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY")
@@ -294,6 +389,191 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId,
String transferNo,
WarehouseTransferRequests.Palletize request) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId));
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse");
}
String customerId = transfer.getCustomerId();
String requestHash = hash(request);
if (WAIT_CUSTOMER_CONFIRMATION.equals(transfer.getStatus())
&& request.idempotencyKey().equals(transfer.getPalletizeIdempotencyKey())) {
if (!requestHash.equals(transfer.getPalletizeRequestHash())) {
throw conflict("Palletization idempotency key was reused with different data");
}
return detail(customerId, transferNo);
}
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
throw conflict("Warehouse transfer is not awaiting PDA receiving");
}
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId));
Map<Long, WarehouseTransferItem> itemById = items.stream()
.collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity()));
Map<Long, Integer> totals = new java.util.HashMap<>();
int palletCartons = 0;
for (WarehouseTransferRequests.Pallet pallet : request.pallets()) {
int itemTotal = pallet.items().stream().mapToInt(WarehouseTransferRequests.PalletItem::packageCount).sum();
if (itemTotal != pallet.cartonCount()) throw badRequest("Each pallet carton count must equal its SKU carton total");
palletCartons = Math.addExact(palletCartons, pallet.cartonCount());
Set<Long> uniqueItems = new HashSet<>();
for (WarehouseTransferRequests.PalletItem item : pallet.items()) {
if (!uniqueItems.add(item.transferItemId()) || !itemById.containsKey(item.transferItemId())) {
throw badRequest("Pallet SKU rows must be unique and belong to this transfer");
}
totals.merge(item.transferItemId(), item.packageCount(), Math::addExact);
}
}
if (palletCartons != transfer.getPackageCount()) throw badRequest("Pallet carton total must equal transfer package count");
for (WarehouseTransferItem item : items) {
if (totals.getOrDefault(item.getId(), 0) != item.getPackageCount()) {
throw badRequest("Palletized carton total does not match declared SKU " + item.getSkuCode());
}
}
String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLETIZE";
List<WarehouseResourceGateway.PalletBatchItemRequest> batchItems = new ArrayList<>();
for (int index = 0; index < request.pallets().size(); index++) {
WarehouseTransferRequests.Pallet pallet = request.pallets().get(index);
batchItems.add(new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(customerId),
transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD",
trim(transfer.getFbaWarehouseCode()), trim(pallet.palletCode())));
}
WarehouseResourceGateway.PalletBatchResponse ensured = warehouseResources.ensurePalletBatch(
new WarehouseResourceGateway.PalletBatchEnsure(overseasWarehouseId, batchKey, batchItems));
WarehouseResourceGateway.PalletBatchResponse confirmed = warehouseResources.confirmPalletBatch(
new WarehouseResourceGateway.PalletBatchCommand(overseasWarehouseId, batchKey,
request.idempotencyKey() + ":CONFIRM"));
if (confirmed == null || confirmed.items() == null || confirmed.items().size() != request.pallets().size()) {
throw conflict("Warehouse pallet batch did not return every pallet");
}
LocalDateTime now = LocalDateTime.now();
for (int index = 0; index < request.pallets().size(); index++) {
int sequenceNo = index + 1;
WarehouseTransferRequests.Pallet source = request.pallets().get(index);
WarehouseResourceGateway.PalletBatchItem resource = confirmed.items().stream()
.filter(value -> value.sequenceNo() == sequenceNo).findFirst()
.orElseThrow(() -> conflict("Warehouse pallet sequence is incomplete"));
WarehouseResourceGateway.WarehouseLocationReference location = warehouseResources.resolveLocation(
overseasWarehouseId, resource.locationId());
WarehouseTransferPallet pallet = new WarehouseTransferPallet();
pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId);
pallet.setSequenceNo(sequenceNo); pallet.setWarehousePalletId(resource.palletId());
pallet.setPalletNo(resource.palletCode()); pallet.setWarehouseLocationId(resource.locationId());
pallet.setWarehouseLocationCode(location.locationCode()); pallet.setWarehouseResourceLockId(resource.resourceLockId());
// 转运测量以打托后的托盘为单位,体积由 PDA 直接录入托盘总体积,不再按单箱尺寸换算。
// 旧表字段仍为 NOT NULL;按托模式不采集长宽高,以 0 兼容存储且不得参与总体积计算。
pallet.setLengthCm(BigDecimal.ZERO); pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO);
pallet.setCartonCount(source.cartonCount()); pallet.setGrossWeightKg(source.grossWeightKg());
pallet.setVolumeCbm(source.volumeCbm().setScale(6, RoundingMode.HALF_UP));
pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setCreatedAt(now); pallet.setUpdatedAt(now);
transferPallets.insert(pallet);
for (WarehouseTransferRequests.PalletItem sourceItem : source.items()) {
WarehouseTransferPalletItem row = new WarehouseTransferPalletItem();
row.setId(IdWorker.getId()); row.setTransferOrderId(transfer.getId()); row.setTransferPalletId(pallet.getId());
row.setTransferItemId(sourceItem.transferItemId()); row.setCustomerId(customerId);
row.setPackageCount(sourceItem.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row);
}
}
transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim());
transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus();
order.setCurrentStatus(WAIT_CUSTOMER_CONFIRMATION); order.setPalletCount(request.pallets().size());
order.setVolumeCbm(transferPallets.listOrder(customerId, transfer.getId()).stream()
.map(WarehouseTransferPallet::getVolumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add));
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"PDA receiving measurement and palletization completed", now);
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail confirmMeasurement(String customerId, String transferNo,
String operatorId,
WarehouseTransferRequests.CustomerConfirm request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
String requestHash = hash(request);
if (Set.of(READY_FOR_DISPATCH, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus())
&& request.idempotencyKey().equals(transfer.getConfirmationIdempotencyKey())) {
if (!requestHash.equals(transfer.getConfirmationRequestHash())) {
throw conflict("Confirmation idempotency key was reused with different data");
}
return detail(customerId, transferNo);
}
if (!WAIT_CUSTOMER_CONFIRMATION.equals(transfer.getStatus())) {
throw conflict("Warehouse transfer measurement is not awaiting customer confirmation");
}
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId));
Map<Long, WarehouseTransferItem> itemById = items.stream()
.collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity()));
List<WarehouseTransferPallet> pallets = transferPallets.listOrder(customerId, transfer.getId());
List<WarehouseTransferPalletItem> palletItems = transferPalletItems.listOrder(customerId, transfer.getId());
WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse(
Long.parseLong(transfer.getOperatingWarehouseId()));
Map<String, List<ConfirmedStockSource>> stockSourcesBySku = new java.util.LinkedHashMap<>();
LocalDateTime now = LocalDateTime.now();
for (WarehouseTransferPallet pallet : pallets) {
for (WarehouseTransferPalletItem row : palletItems.stream()
.filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) {
WarehouseTransferItem item = itemById.get(row.getTransferItemId());
String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transferNo + ":" + pallet.getSequenceNo() + ":" + item.getId();
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId,
transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(),
null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()),
transfer.getInboundOrderId(), transferNo, transferNo, item.getSkuCode(), item.getProductNameZh(), null,
pallet.getWarehousePalletId(), pallet.getPalletNo(), transferNo, pallet.getWarehouseLocationId(),
pallet.getWarehouseLocationCode(), row.getPackageCount(), transferNo,
"WAREHOUSE_TRANSFER_CONFIRMED:" + transferNo, operationKey, trim(request.remark())));
stockSourcesBySku.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>())
.add(new ConfirmedStockSource(result.balanceId(), result.positionId(), row.getPackageCount()));
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId);
// 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。
receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId());
receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId());
receipt.setOrderId(transfer.getLastlegOrderId());
receipt.setInboundCode(transferNo); receipt.setSkuCode(item.getSkuCode()); receipt.setProductNameZh(item.getProductNameZh());
receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo());
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode());
receipt.setBatchNo(transferNo); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount()));
receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId());
receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); stockReceipts.insert(receipt);
}
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
RelabelResponses.Detail relabel = createLinkedRelabel(customerId, operatorId, transfer, itemById,
stockSourcesBySku);
transfer.setRelabelApplicationId(relabel.application().id());
transfer.setRelabelNo(relabel.application().relabelNo()); transfer.setStatus(WAIT_RELABEL);
} else {
if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) {
throw conflict("A destination warehouse is required before dispatch");
}
createDispatchPlan(customerId, transfer, items, pallets, palletItems, now);
transfer.setStatus(READY_FOR_DISPATCH);
}
transfer.setConfirmationIdempotencyKey(request.idempotencyKey().trim());
transfer.setConfirmationRequestHash(requestHash); transfer.setCustomerConfirmedAt(now); transfer.setUpdatedAt(now);
transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus();
order.setCurrentStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? WAIT_RELABEL : READY_FOR_DISPATCH);
order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus());
order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"Customer confirmed PDA measurements; actual inventory stocked in", now);
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Allocate request) {
@@ -490,6 +770,182 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
@Transactional(readOnly = true)
public List<WarehouseTransferResponses.Summary> pdaCandidates(long overseasWarehouseId) {
return transfers.pdaCandidates(String.valueOf(overseasWarehouseId)).stream().map(value ->
new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(),
value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(),
value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(),
Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(),
value.getEstimatedArrivalAt(), value.getCreatedAt())).toList();
}
private void validateRelabelRequest(WarehouseTransferRequests.Create request) {
if (!request.needRelabel()) {
if (request.relabelItems() != null && !request.relabelItems().isEmpty()) {
throw badRequest("Relabel items require needRelabel=true");
}
return;
}
if (request.relabelItems() == null || request.relabelItems().isEmpty()) return;
if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName())
|| !StringUtils.hasText(request.relabelContactPhone()) || request.relabelChargeAmount() == null) {
throw badRequest("Relabel deadline, contact and charge are required");
}
Set<String> declaredSkus = request.items().stream().map(item -> item.skuCode().trim()).collect(Collectors.toSet());
Set<String> marks = new HashSet<>();
for (WarehouseTransferRequests.RelabelItem row : request.relabelItems()) {
String type = row.operationType().trim().toUpperCase();
if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) {
throw badRequest("Unsupported relabel operation type");
}
if (!declaredSkus.contains(row.originalSku().trim())) {
throw badRequest("Relabel SKU must exist in transfer cargo: " + row.originalSku());
}
if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type))
&& (!StringUtils.hasText(row.fbaCartonMark()) || !StringUtils.hasText(row.destinationWarehouseCode()))) {
throw badRequest("Carton relabel requires new FBA carton mark and destination warehouse");
}
if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) && !StringUtils.hasText(row.newSku())) {
throw badRequest("SKU relabel requires a new SKU");
}
for (String mark : row.customerCartonMarks()) {
if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique");
}
}
if (marks.size() > request.packageCount()) throw badRequest("Relabel carton marks cannot exceed transfer cartons");
}
private void insertRelabelBindings(String customerId, long transferId,
List<WarehouseTransferRequests.RelabelItem> rows, LocalDateTime now) {
if (rows == null || rows.isEmpty()) return;
for (WarehouseTransferRequests.RelabelItem source : rows) {
for (String mark : source.customerCartonMarks()) {
WarehouseTransferRelabelBinding row = new WarehouseTransferRelabelBinding();
row.setId(IdWorker.getId()); row.setTransferOrderId(transferId); row.setCustomerId(customerId);
row.setOperationType(source.operationType().trim().toUpperCase());
row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(source.originalSku().trim());
row.setNewSku(trim(source.newSku())); row.setQuantity(source.quantity());
row.setFbaCartonMark(trim(source.fbaCartonMark()));
row.setDestinationWarehouseCode(trim(source.destinationWarehouseCode()));
row.setRemark(trim(source.remark())); row.setCreatedAt(now); relabelBindings.insert(row);
}
}
}
private RelabelResponses.Detail createLinkedRelabel(String customerId, String operatorId,
WarehouseTransferOrder transfer,
Map<Long, WarehouseTransferItem> itemById,
Map<String, List<ConfirmedStockSource>> stockSourcesBySku) {
Map<String, WarehouseTransferItem> itemsBySku = itemById.values().stream()
.collect(Collectors.toMap(WarehouseTransferItem::getSkuCode, Function.identity(), (left, right) -> left));
List<RelabelRequests.Item> items = new ArrayList<>();
for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) {
WarehouseTransferItem source = itemsBySku.get(binding.getOriginalSku());
ConfirmedStockSource stockSource = nextStockSource(stockSourcesBySku.get(binding.getOriginalSku()));
if (source == null || stockSource == null) {
throw conflict("Relabel binding cannot find confirmed inventory for SKU " + binding.getOriginalSku());
}
List<RelabelRequests.SkuItem> skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType())
? List.of(new RelabelRequests.SkuItem(binding.getOriginalSku(), binding.getNewSku(),
binding.getQuantity(), binding.getRemark())) : List.of();
items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(),
binding.getOriginalSku(), null, null, null, source.getProductNameZh(), 1, 1,
StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(),
transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(),
null, null, null, binding.getCustomerCartonMark(), binding.getFbaCartonMark(),
binding.getDestinationWarehouseCode(), binding.getOperationType(), skuItems));
}
RelabelRequests.Create request = new RelabelRequests.Create(transfer.getTransferNo(),
transfer.getOperatingWarehouseId(), transfer.getLabelDeadline(), transfer.getRelabelContactName(),
transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单",
"WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items,
trim(transfer.getRelabelDeliveryMethod()), false);
return relabelService.create(customerId, operatorId, request);
}
private static ConfirmedStockSource nextStockSource(List<ConfirmedStockSource> sources) {
if (sources == null) return null;
for (ConfirmedStockSource source : sources) {
if (source.remainingPackages > 0) {
source.remainingPackages--;
return source;
}
}
return null;
}
private static final class ConfirmedStockSource {
private final Long balanceId;
private final Long positionId;
private int remainingPackages;
private ConfirmedStockSource(Long balanceId, Long positionId, int remainingPackages) {
this.balanceId = balanceId;
this.positionId = positionId;
this.remainingPackages = remainingPackages;
}
}
private void createDispatchPlan(String customerId, WarehouseTransferOrder transfer,
List<WarehouseTransferItem> items, List<WarehouseTransferPallet> pallets,
List<WarehouseTransferPalletItem> palletItems, LocalDateTime now) {
long planId = IdWorker.getId();
InboundPlan plan = new InboundPlan(); plan.setId(planId); plan.setPlanNo("WT-" + transfer.getTransferNo());
plan.setCustomerId(customerId); plan.setSourceOrderId(transfer.getLastlegOrderId());
plan.setContainerNo(transfer.getTransferNo()); plan.setOverseasWarehouseId(Long.parseLong(transfer.getOperatingWarehouseId()));
plan.setPlannedArrivalDate(transfer.getEstimatedArrivalAt().toLocalDate()); plan.setPalletType("STANDARD");
plan.setAssignedFlag(true); plan.setMergeRequired(false); plan.setUrgentFlag(false);
plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Customer-confirmed transfer measurements");
plan.setStatus("COMPLETED"); plan.setLineCount(items.size()); plan.setPalletCount(pallets.size());
plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
.reduce(BigDecimal.ZERO, BigDecimal::add)); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0);
inboundPlans.insert(plan);
Map<Long, Long> lineIds = new java.util.HashMap<>();
for (WarehouseTransferItem source : items) {
InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId);
line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo());
line.setDestinationType("FBA"); line.setFbaWarehouseCode(transfer.getFbaWarehouseCode());
line.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); line.setDestinationWarehouseName(transfer.getFbaWarehouseCode());
line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(source.getPackageCount());
line.setPlannedPalletCount((int) palletItems.stream().filter(row -> row.getTransferItemId().equals(source.getId())).count());
inboundPlanLines.insert(line); lineIds.put(source.getId(), line.getId());
}
for (WarehouseTransferPallet source : pallets) {
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(IdWorker.getId()); pallet.setCustomerId(customerId);
pallet.setPlanId(planId); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD");
pallet.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); pallet.setDestinationWarehouseName(transfer.getFbaWarehouseCode());
pallet.setAllocatedVolumeCbm(source.getVolumeCbm()); pallet.setSequenceNo(source.getSequenceNo());
pallet.setWarehousePalletId(source.getWarehousePalletId()); pallet.setWarehouseLocationId(source.getWarehouseLocationId());
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(planId); allocation.setLineId(lineIds.get(sourceItem.getTransferItemId()));
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount());
allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(source.getVolumeCbm());
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
}
}
}
private static List<String> splitMarks(String value) {
if (!StringUtils.hasText(value)) return List.of();
return java.util.Arrays.stream(value.split("[,\\n\\r]+"))
.map(String::trim).filter(StringUtils::hasText).distinct().toList();
}
private static String escape(String value) {
return org.springframework.web.util.HtmlUtils.htmlEscape(Objects.toString(value, ""));
}
private static String text(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private Long createInboundSnapshot(String customerId, long inboundOrderId, String transferNo,
WarehouseTransferRequests.Create request, String requestHash,
LocalDateTime now) {
@@ -544,10 +1000,11 @@ public class WarehouseTransferService {
private void insertTransferCarton(String customerId, long transferId, long transferItemId,
String transferNo, int cartonIndex, int totalCartons,
LocalDateTime now) {
String customerCartonMark, LocalDateTime now) {
WarehouseTransferCarton carton = new WarehouseTransferCarton();
carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId);
carton.setCustomerId(customerId); carton.setCartonMark(transferNo + "#" + totalCartons + "-" + cartonIndex);
carton.setCustomerCartonMark(trim(customerCartonMark));
carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED");
carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton);
}
@@ -1526,6 +1526,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash'));
PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at'));
PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式'''));
PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间'''));
PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1556,6 +1562,45 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报';
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='customer_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN customer_carton_mark VARCHAR(255) NULL COMMENT ''客户原箱唛,用于换标现场绑定'' AFTER carton_mark'));
PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carton_stmt; DEALLOCATE PREPARE transfer_customer_carton_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet (
id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号',
warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码',
warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码',
warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数',
length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg',
volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态',
completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', 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_transfer_pallet_seq(transfer_order_id,sequence_no),
UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id),
KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id),
KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型',
customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU',
new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数',
fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓',
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark),
KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
id BIGINT NOT NULL COMMENT '雪花主键',
transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID',
@@ -2169,6 +2214,7 @@ ALTER TABLE ll_security_projection
-- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN');
SET @platform_admin_overseas_authorities = JSON_ARRAY(
'platform:overseas-warehouse:warehouse-transfer:approve',
'platform:finance:bill:adjust',
'platform:finance:bill:update',
'platform:finance:bill:void',
@@ -549,10 +549,19 @@ paths:
'200':
description: 统一 ApiResponse 包装的候选托盘数组
content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchCandidate' } } } }
/api/platform/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}:
delete:
summary: 移除已排车托盘
description: 权限 platform:overseas-warehouse:dispatch:edit;仅待发车、未拣货且尚未生成出库单时允许,成功后释放托盘锁并刷新排车汇总。
security: [{ bearerAuth: [] }]
parameters:
- { name: dispatchNo, in: path, required: true, schema: { type: string } }
- { name: itemId, in: path, required: true, schema: { type: integer, format: int64 } }
responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单或托盘不存在 }, '409': { description: 已生成出库单或排车已开始执行 } }
/api/platform/v1/last-leg/dispatches/pre-arrange:
post:
summary: 创建智能预排车单
description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;本接口只负责预排车创建,编辑、取消、转出库派送和 PDA 发车使用各自独立接口与权限。
description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围。首次推送创建 PENDING_DEPARTURE 排车单,未生成出库单前保留虚拟车草稿,再次推送刷新原排车单明细而不重复建单。
security: [{ bearerAuth: [] }]
requestBody:
required: true
@@ -1203,6 +1212,15 @@ paths:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
/api/platform/v1/last-leg/relabel-applications/{relabelNo}/wallet-settlement/retry:
post:
summary: 重试失败的换标费用结算
description: 权限 platform:overseas-warehouse:relabel-order:status;仅允许已完成且资金状态为SETTLE_FAILED的换标单,使用新幂等键重新发送结算指令。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WalletFreezeRetry' } } } }
responses: { '200': { description: 结算重试已提交 }, '409': { description: 当前换标单不满足重试条件 } }
/api/platform/v1/last-leg/relabel-applications/{relabelNo}/files:
post:
summary: 上传总换标PDF或明细换标PDF
@@ -1271,6 +1289,17 @@ paths:
responses:
'200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } }
'409': { description: 幂等键、库存或状态冲突 }
/api/merchant/v1/last-leg/relabel-applications/historical-orders:
get:
summary: 查询贴标申请历史订单候选
description: 按海外仓和关键字查询客户历史尾程订单,并包含贴标出库单(RELABEL_OUTBOUND),供贴标申请订单下拉筛选。
security: [{ bearerAuth: [] }]
parameters:
- { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } }
- { name: keyword, in: query, required: false, schema: { type: string, maxLength: 128 } }
responses:
'200': { description: 历史订单候选, content: { application/json: { schema: { type: object, properties: { code: { type: string, example: '200' }, data: { type: array, items: { $ref: '#/components/schemas/RelabelHistoricalOrder' } } } } } } }
'403': { description: 缺少客户换标权限 }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}:
get:
summary: 查询客户换标申请详情
@@ -2419,6 +2448,18 @@ components:
batchNo: { type: string, nullable: true }
warehouseLocationCode: { type: string, nullable: true }
availablePackages: { type: integer, format: int64 }
RelabelHistoricalOrder:
type: object
properties:
id: { type: string, nullable: true }
orderNo: { type: string }
sourceOrderNo: { type: string, nullable: true }
outboundOrderNo: { type: string, nullable: true }
relabelOrderNo: { type: string, nullable: true }
orderType: { type: string, nullable: true }
status: { type: string, nullable: true }
packageCount: { type: integer, nullable: true }
createdAt: { type: string, format: date-time, nullable: true }
RelabelCustomerConfirm:
type: object
required: [idempotencyKey]
@@ -2467,6 +2508,12 @@ components:
properties:
status: { $ref: '#/components/schemas/RelabelStatus' }
remark: { type: string, nullable: true, maxLength: 1000 }
WalletFreezeRetry:
type: object
required: [idempotencyKey]
properties:
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
remark: { type: string, nullable: true, maxLength: 1000 }
MerchantApiResponse:
type: object
required: [code, data]
@@ -154,6 +154,12 @@ paths:
summary: 扫码查询托盘及库存内容
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
responses: { '200': { description: Token绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } }
/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label:
post:
summary: 为换标拆托幂等生成并返回可打印的新托盘标签
description: 返回空托盘编码、初始版本和打印HTML;随后可将该托盘作为relabel-disassemble的target。库存移入后仍继承原库存余额对应的客户和入库单。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/NewRelabelPallet' } } } }
responses: { '200': { description: 新托盘及可打印标签 }, '409': { description: 换标单未确认或不属于Token绑定仓 } }
/api/pda/v1/last-leg/pallet-operations/assemble:
post:
summary: 将多个来源托盘的指定库存行组到目标托盘
@@ -411,14 +417,14 @@ components:
inventoryPositionCount: { type: integer }
WarehousePallet:
type: object
required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory]
required: [palletId, palletCode, status, version, occupiedPackages, inventoryPositionCount, inventory]
properties:
palletId: { type: string, pattern: '^[0-9]+$' }
palletCode: { type: string }
status: { type: string }
version: { type: integer }
locationId: { type: string, pattern: '^[0-9]+$' }
locationCode: { type: string }
locationId: { type: string, pattern: '^[0-9]+$', nullable: true }
locationCode: { type: string, nullable: true }
occupiedPackages: { type: integer, format: int64 }
inventoryPositionCount: { type: integer }
inventory: { type: array, items: { $ref: '#/components/schemas/InventoryItem' } }
@@ -548,6 +554,12 @@ components:
type: object
required: [code]
properties: { code: { type: string, minLength: 1, maxLength: 128 } }
NewRelabelPallet:
type: object
required: [relabelNo, idempotencyKey]
properties:
relabelNo: { type: string, minLength: 1, maxLength: 64 }
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
IdempotentAction:
type: object
required: [idempotencyKey]
@@ -31,6 +31,7 @@ import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
@@ -83,6 +84,7 @@ class DeliveryTransferServiceTest {
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock DropshipCartonMapper dropshipCartons;
@Mock DropshipSkuInventoryMapper dropshipSkuInventory;
@Mock WarehouseTransferPalletMapper warehouseTransferPallets;
private DeliveryTransferService service;
@@ -110,6 +112,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory);
ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets);
}
@Test
@@ -15,7 +15,7 @@ class DispatchCandidateMapperContractTest {
"planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt",
"overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo",
"destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode",
"packageCount", "volumeCbm", "sourceStage", "cartonMarks"
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks"
);
@Test
@@ -24,6 +24,8 @@ class DispatchCandidateMapperContractTest {
String.class, List.class, String.class, String.class, String.class, String.class));
assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("lockSelected",
String.class, List.class));
assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("relabelAvailable",
String.class, String.class, String.class));
}
@Test
@@ -49,6 +51,20 @@ class DispatchCandidateMapperContractTest {
}
}
@Test
void candidateMarksFollowPdaRelabelPalletBinding() throws Exception {
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("rp.id=ri.source_inventory_position_id"));
assertTrue(sql.contains("rp.pallet_id=p.warehouse_pallet_id"));
assertTrue(sql.contains("ri.fba_carton_mark"));
assertTrue(sql.contains("AS relabelCartonMarks"));
}
}
private void assertConstructorColumnOrder(Method method) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
int previous = -1;
@@ -252,6 +252,8 @@ class DispatchPdaWorkflowServiceTest {
assertNotNull(order.getDepartAt());
assertEquals(order.getDepartAt(), order.getUpdatedAt());
verify(orders).updateById(order);
assertEquals("DEPARTED", order.getStatus());
assertEquals("OUTBOUNDED", outbound.getStatus());
}
@Test
@@ -21,6 +21,7 @@ import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -57,6 +58,7 @@ class DispatchServiceTest {
@Mock DispatchDraftContainerMapper draftContainers;
@Mock DispatchPdaTaskMapper pdaTasks;
@Mock LastlegOrderMapper tailOrders;
@Mock OutboundOrderMapper outboundOrders;
private DispatchService service;
@BeforeEach
@@ -71,6 +73,7 @@ class DispatchServiceTest {
ReflectionTestUtils.setField(service, "draftContainers", draftContainers);
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
lenient().when(bolLines.findByDispatchOrder(any(), any())).thenReturn(List.of());
@@ -185,7 +188,7 @@ class DispatchServiceTest {
}
@Test
void preArrangeConvertsOwnedDraftLockInsteadOfInsertingAnotherLock() {
void preArrangeKeepsVirtualVehicleAndItsLocksEditableUntilOutboundExists() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
DispatchPalletLock lock = new DispatchPalletLock();
lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
@@ -197,16 +200,40 @@ class DispatchServiceTest {
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1"));
assertNotNull(lock.getDispatchOrderId());
assertNull(lock.getDraftToken());
assertNull(lock.getDraftContainerId());
assertEquals("RELEASED", draft.getStatus());
assertNotNull(draft.getReleasedAt());
assertEquals("draft-1", lock.getDraftToken());
assertEquals(21L, lock.getDraftContainerId());
assertEquals("ACTIVE", draft.getStatus());
assertNull(draft.getExpiresAt());
verify(locks).updateById(lock);
verify(locks, never()).insert(any(DispatchPalletLock.class));
verify(draftContainers).delete(any(LambdaQueryWrapper.class));
verify(drafts).updateById(draft);
}
@Test
void removingUnpickedPalletReleasesLockAndRefreshesDispatchTotals() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
dispatch.setStatus(DispatchService.PENDING_DEPARTURE); dispatch.setPickedPackageCount(0);
DispatchItem removed = new DispatchItem();
removed.setId(11L); removed.setPlanPalletId(10L); removed.setWarehousePalletId(20L);
DispatchItem remaining = new DispatchItem();
remaining.setId(12L); remaining.setPlanPalletId(12L); remaining.setPackageCount(7);
DispatchPalletLock lock = new DispatchPalletLock();
lock.setPlanPalletId(10L); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
when(items.lockById("100", 1L, 11L)).thenReturn(removed);
when(locks.selectList(any())).thenReturn(List.of(lock));
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(remaining));
DispatchResponse result = service.removeItem("PC1", 11L);
assertEquals(1, result.palletCount());
assertEquals(7, result.packageCount());
assertEquals("RELEASED", lock.getStatus());
verify(items).deleteById(11L);
verify(orders).updateById(dispatch);
}
@Test
void activeDraftOnlyRestoresLocksStillOwnedByThatDraft() {
DispatchDraft draft = new DispatchDraft();
@@ -24,7 +24,8 @@ class PdaPalletOperationControllerContractTest {
.map(Method::getDeclaredAnnotations).flatMap(Arrays::stream)
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/relabel-disassemble", "/swap", "/unbind"), paths);
assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble",
"/relabel-disassemble", "/swap", "/unbind"), paths);
}
@Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception {
@@ -85,6 +85,22 @@ class PdaPalletOperationServiceTest {
verifyNoInteractions(mapper);
}
@Test void createsPrintableRelabelPalletForConfirmedApplication() {
RelabelApplication application = new RelabelApplication(); application.setRelabelNo("RL-1");
application.setStatus("READY_FOR_OUTBOUND"); application.setOverseasWarehouseId("88");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application);
when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1")).thenReturn(
new PalletPhysicalGateway.PalletReference(30L, "PAL-NEW-1", 88L, 1030L, "IDLE", null, 0));
var result = service.createRelabelPallet(identity(),
new PalletOperationRequests.NewRelabelPallet("RL-1", "new-pallet-1"));
assertEquals("PAL-NEW-1", result.palletCode());
assertEquals(0, result.palletVersion());
assertTrue(result.printableHtml().contains("托盘标签"));
assertTrue(result.printableHtml().contains("RL-1"));
}
@Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() {
var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L,
"OCCUPIED", null, 1);
@@ -168,17 +184,14 @@ class PdaPalletOperationServiceTest {
verifyNoInteractions(physical);
}
@Test void relabelDisassembleMovesOnlySelectedHeldCarton() {
var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L,
@Test void relabelDisassembleMovesOnlySelectedHeldCartonWithoutRequiringLocations() {
var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null,
"OCCUPIED", null, 1);
var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L,
var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null,
"IDLE", null, 2);
when(physical.resolve("SOURCE")).thenReturn(sourcePallet);
when(physical.resolve("TARGET")).thenReturn(targetPallet);
when(physical.resolveLocation(eq(88L), anyLong())).thenAnswer(invocation ->
new PalletPhysicalGateway.LocationReference(invocation.getArgument(1), "LOC-" + invocation.getArgument(1),
"STORAGE", "ACTIVE"));
when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any()))
when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any()))
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
RelabelApplication application = new RelabelApplication(); application.setId(600L);
@@ -219,10 +232,18 @@ class PdaPalletOperationServiceTest {
assertEquals(1L, source.getHoldPackages());
assertEquals(0L, source.getAvailablePackages());
assertNotNull(target.get()); assertEquals(1L, target.get().getHoldPackages());
assertNull(target.get().getWarehouseLocationId());
assertNull(target.get().getWarehouseLocationCode());
verify(physical, never()).resolveLocation(anyLong(), anyLong());
verify(relabelPalletMutations).moveSelectedItems(eq(application), anyMap(), argThat(targets ->
targets.size() == 1 && targets.get(100L) == target.get()));
verify(balances, never()).updateById(any(InventoryBalance.class));
assertEquals("RELABEL_PALLET_DISASSEMBLE", operation.get().getOperationType());
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers, times(2)).insert(ledger.capture());
assertTrue(ledger.getAllValues().stream().allMatch(value -> "换标拆托:split".equals(value.getRemark())));
assertTrue(ledger.getAllValues().stream().anyMatch(value -> value.getDeltaPackages() == -1));
assertTrue(ledger.getAllValues().stream().anyMatch(value -> value.getDeltaPackages() == 1));
}
@Test void relabelDisassembleReplayDoesNotRevalidateChangedInventoryOrPallets() throws Exception {
@@ -28,6 +28,8 @@ import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -71,6 +73,7 @@ class RelabelApplicationServiceTest {
@Mock OutboundOrderMapper outboundOrders;
@Mock OutboundPalletItemMapper outboundItems;
@Mock OutboundPdaTaskMapper outboundTasks;
@Mock WarehouseTransferOrderMapper warehouseTransfers;
@Mock OverseasFeeSourceService feeSources;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@@ -92,6 +95,7 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
ReflectionTestUtils.setField(service, "outboundTasks", outboundTasks);
ReflectionTestUtils.setField(service, "warehouseTransfers", warehouseTransfers);
ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1");
lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> {
@@ -363,6 +367,7 @@ class RelabelApplicationServiceTest {
void completedReturnRelabelOrderEntersPendingAssignment() {
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
application.setLastlegOrderId(21L);
application.setFulfillmentStatus("PRE_DISPATCH_CREATED");
LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PROCESSING");
order.setOrderType("RETURN_RELABEL");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
@@ -373,6 +378,7 @@ class RelabelApplicationServiceTest {
new RelabelRequests.ChangeStatus("COMPLETED", "finished"));
assertEquals("COMPLETED", application.getStatus());
assertEquals("OUTBOUNDED", application.getFulfillmentStatus());
assertEquals("PENDING_ASSIGNMENT", order.getCurrentStatus());
assertEquals("COMPLETED", order.getRelabelStatus());
}
@@ -416,6 +422,29 @@ class RelabelApplicationServiceTest {
verify(applications, times(1)).updateById(application);
}
@Test
void transferRelabelConfirmationReleasesTransferForDispatch() {
RelabelApplication application = confirmationApplication("TRUCK", false);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application);
when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
when(orders.selectById(88L)).thenReturn(transferOrder);
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-transfer", "accepted"));
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
assertNotNull(transfer.getRelabelCompletedAt());
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
assertEquals(RelabelApplicationService.READY_FOR_OUTBOUND, transferOrder.getRelabelStatus());
verify(warehouseTransfers).updateById(transfer);
verify(orders).updateById(transferOrder);
}
@Test
void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() {
RelabelApplication application = confirmationApplication("EXPRESS", true);
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseTransferControllerPermissionTest {
@Test
void readEndpointsAndReviewUseTheirDedicatedAuthorities() throws Exception {
PreAuthorize page = WarehouseTransferController.class
.getMethod("page", AuthenticatedUser.class, String.class, WarehouseTransferRequests.Query.class)
.getAnnotation(PreAuthorize.class);
PreAuthorize detail = WarehouseTransferController.class
.getMethod("detail", AuthenticatedUser.class, String.class, String.class)
.getAnnotation(PreAuthorize.class);
PreAuthorize cartonLabel = WarehouseTransferController.class
.getMethod("cartonLabel", AuthenticatedUser.class, String.class, String.class)
.getAnnotation(PreAuthorize.class);
PreAuthorize review = WarehouseTransferController.class
.getMethod("review", AuthenticatedUser.class, String.class, String.class,
WarehouseTransferRequests.Review.class)
.getAnnotation(PreAuthorize.class);
String readAuthority = "hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')";
assertThat(page.value()).isEqualTo(readAuthority);
assertThat(detail.value()).isEqualTo(readAuthority);
assertThat(cartonLabel.value()).isEqualTo(readAuthority);
assertThat(review.value()).isEqualTo(
"hasAuthority('" + OverseasPermissions.WAREHOUSE_TRANSFER_APPROVE + "')");
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.transfer.dto;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WarehouseTransferPalletValidationTest {
@Test
void palletAcceptsDirectTotalVolumeAndWeightWithoutCartonDimensions() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5,
new BigDecimal("1.250000"), new BigDecimal("420.50"),
List.of(new WarehouseTransferRequests.PalletItem(101L, 5)));
assertTrue(validator.validate(pallet).isEmpty());
assertEquals(new BigDecimal("1.250000"), pallet.volumeCbm());
assertEquals(new BigDecimal("420.50"), pallet.grossWeightKg());
}
}
@Test
void palletRejectsNonPositiveTotalVolume() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5,
BigDecimal.ZERO, new BigDecimal("420.50"),
List.of(new WarehouseTransferRequests.PalletItem(101L, 5)));
assertEquals(1, validator.validate(pallet).size());
}
}
}
+5
View File
@@ -262,3 +262,8 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
- 其他非零值:原样透传 Maven 失败退出码。
脚本不设置 `MAVEN_OPTS`、数据库 URL 或测试账号;环境参数仅为可选的 `$MavenCommand`(默认 `mvn`),用于指定本机 Maven 可执行文件。
## 海外换标定向验收补充
- `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。
- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。
@@ -103,11 +103,11 @@ test('dropship measurement completes the formal palletization workflow before sm
assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/)
assert.match(source, /cartons\/resolve/)
assert.match(source, /cartons\/count/)
assert.match(source, /pallets\/create-and-bind/)
assert.match(source, /系统发码重放不新增托盘且业务码和计数稳定/)
assert.match(source, /拒绝创建没有空托或计数漂移/)
assert.match(source, /pallets\/bind-pending/)
assert.match(source, /预生成托盘绑定重放不新增托盘且计数稳定/)
assert.match(source, /预生成托盘覆盖测量冻结的全部箱数/)
assert.doesNotMatch(source, /palletHash|palletDate/)
assert.match(source, /pallets\/\$\{palletId\}\/seal/)
assert.match(source, /pallets\/\$\{asId\(pallet.palletId\)\}\/seal/)
assert.match(source, /重复清点操作按operationId幂等返回/)
assert.doesNotMatch(source, /pallets\/open/)
assert.doesNotMatch(source, /pallets\/\$\{palletId\}\/scan/)
@@ -124,7 +124,7 @@ test('confirmed package count reconciles an uncertain transport result before re
async function exerciseSystemPalletScenario(fault = '') {
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [] }
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [{ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: 0, status: 'OPEN', version: 1 }] }
const seenCounts = new Map()
const seenCreates = new Set()
const checks = []
@@ -139,32 +139,28 @@ async function exerciseSystemPalletScenario(fault = '') {
async request(method, path, body) {
if (path.includes('/pending?')) return response([task])
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, expectedSkuQuantity: 10 })
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10 }] })
if (path.endsWith('/cartons/count')) {
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
task.countedCartons += 1
const targetPallet = task.pallets.find(pallet => pallet.palletNo === body.palletNo)
if (body.palletNo) { task.boundCartons += 1; targetPallet.cartonCount += 1; targetPallet.version += 1 }
else task.unboundCartons += 1
const result = response({ ...task, carton: { boxCode: body.boxCode, expectedSkuQuantity: 10, actualSkuQuantity: 10,
discrepancyStatus: 'MATCH' }, pallet: body.palletNo ? targetPallet : null })
const result = response({ ...task, carton: { boxCode: body.boxCode, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10, actualQuantity: 10, discrepancyStatus: 'MATCH' }] }, pallet: body.palletNo ? targetPallet : null })
seenCounts.set(body.operationId, result)
return result
}
if (path.endsWith('/create-and-bind')) {
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'warehouseId'])
if (path.endsWith('/bind-pending')) {
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'palletNo', 'warehouseId'])
if (seenCreates.has(body.operationId)) {
if (fault === 'duplicate-replay') task.pallets.push({ ...task.pallets[0], palletId: '999', palletNo: 'DP-260909-0999' })
return response(task)
}
if (!task.unboundCartons) {
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'OPEN', version: 0 })
return response(null, 'A0409')
}
seenCreates.add(body.operationId)
task.pallets.push({ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: task.unboundCartons, status: 'OPEN', version: 0 })
task.pallets[0].cartonCount += task.unboundCartons
task.boundCartons += task.unboundCartons
task.unboundCartons = 0
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'SEALED', version: 1 })
return response(task)
}
if (path.endsWith('/84/seal')) {
@@ -175,21 +171,21 @@ async function exerciseSystemPalletScenario(fault = '') {
throw new Error(`Unexpected scenario request: ${method} ${path}`)
},
}
const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2, expectedPalletCount: 1 } })
assert.deepEqual(sealed.sealedCartonMarks, ['CODE#2-1', 'CODE#2-2'])
assert.deepEqual(sealed.sealedSourcePalletNos, ['DP-260909-0001'])
if (!fault) assert.deepEqual(sealed.sealedSourcePalletNos, ['DP-260909-0001'])
return checks
}
test('system-issued pallet HTTP scenario covers count, replay, empty rejection and completed replay', async () => {
test('pregenerated pallet scenario covers multi-SKU count, bind replay and sealing', async () => {
const checks = await exerciseSystemPalletScenario()
assert.ok(checks.length >= 15)
for (const name of ['审核通过时已按预计托数预生成全部DP托盘码', '重复清点没有重复累计箱数', '待组托箱绑定到OPEN托盘且不再游离', '预生成托盘绑定重放不新增托盘且计数稳定', '预生成托盘覆盖测量冻结的全部箱数', '全部箱数封托后入仓单允许进入WAIT_SPLIT']) assert.ok(checks.some(check => check.name === name), name)
assert.deepEqual(checks.filter(check => !check.passed), [])
})
test('system-issued pallet HTTP regression detects duplicate replay and orphan empty pallets', async () => {
test('pregenerated pallet regression detects duplicate replay and orphan empty pallets', async () => {
const replayChecks = await exerciseSystemPalletScenario('duplicate-replay')
assert.ok(replayChecks.some(check => check.name.includes('系统发码重放不新增托盘') && !check.passed))
assert.ok(replayChecks.some(check => check.name.includes('预生成托盘绑定重放不新增托盘') && !check.passed))
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
assert.ok(emptyChecks.some(check => check.name.includes('拒绝创建没有空托或计数漂移') && !check.passed))
assert.ok(emptyChecks.some(check => check.name.includes('预生成托盘覆盖测量冻结的全部箱数') && !check.passed))
})
@@ -191,6 +191,13 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
&& bound?.pallets?.length === expectedPalletCount,
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
const replayBindResponse = await context.request('POST',
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
const replayBind = context.apiSuccess(MODULE, '预生成托盘绑定同键重放', replayBindResponse, '', OPTIONS)
context.assert(MODULE, '预生成托盘绑定重放不新增托盘且计数稳定', replayBindResponse,
replayBind?.pallets?.length === expectedPalletCount
&& Number(replayBind?.boundCartons) === Number(bound?.boundCartons)
&& Number(replayBind?.unboundCartons) === 0, '', OPTIONS)
} else {
const expectedPallet = generatedPallets[Math.min(index, generatedPallets.length - 1)]
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
@@ -113,12 +113,12 @@ test('海外空库基线为固定平台管理员提供完整version0授权投影
'platform:overseas-warehouse:product:create',
'platform:overseas-warehouse:product:edit',
])
assert.equal(authorities.length, 105)
assert.equal(authorities.length, 106)
assert.equal(new Set(authorities).size, authorities.length, 'baseline authorities must not contain duplicates')
assert.equal(authorities.filter((item) => item.startsWith('platform:finance:')).length, 34)
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-business:')).length, 11)
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-master-data:')).length, 15)
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 45)
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 46)
for (const authority of authorities) {
assert.ok(sql.includes(`'${authority}'`), `authority is absent from the domestic IAM baseline: ${authority}`)
}
@@ -186,3 +186,10 @@ test('三期权限迁移删除退役权限并只保留 canonical 权限', () =>
assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102140 AND 5102149/)
assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102150 AND 5102154/)
})
test('海外仓物理托盘初始化与模块基线一致', () => {
const baseline = readFileSync(new URL('../../warehouse-overseas-service/src/main/resources/db/warehouse.sql', import.meta.url), 'utf8')
const inline = overseasSql.match(/-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE\r?\n([\s\S]*?)\r?\n\r?\n-- END INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/)?.[1]
assert.ok(inline)
assert.equal(inline.slice(inline.indexOf('-- This script is run')).replace(/\r\n/g, '\n').trim(), baseline.replace(/\r\n/g, '\n').trim())
})
@@ -50,7 +50,8 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/)
assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/)
assert.doesNotMatch(acceptance, /this\.modules\.at\(-1\)\?\.success !== true/)
assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/)
assert.match(acceptance, /measurement\.processStatus === expectedProcessStatus/)
assert.match(acceptance, /prepareDropshipManualCount \? 'WAIT_PALLETIZE' : 'WAIT_SPLIT'/)
assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/)
assert.match(acceptance, /waitSplitVariant\(sequence\)/)
assert.match(acceptance, /packageCountPerSku/)
@@ -18,7 +18,7 @@ test('order fixture defaults to FBA and preserves explicit OVERSEAS or PRIVATE r
const orders = read('orders.mjs')
assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/)
assert.match(orders, /destinationType: master\.destinationType/)
assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/)
assert.match(orders, /fulfillmentType: dropship \? 'ONE_CLICK_DROPSHIP' : 'STANDARD'/)
assert.match(orders, /skuCodes: payload\.cargoes\.map\(\(cargo\) => cargo\.skuNo\)/)
assert.match(orders, /skuLines: payload\.cargoes\.map\(\(cargo\) => \(\{/)
assert.match(orders, /destinationType,\s*\n\s*\}\)/)
@@ -49,3 +49,19 @@ test('module records the marker-owned dispatch without attempting excluded clean
assert.match(source, /finalStatus = arranged\.data\?\.status/)
assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/)
})
test('module prefers marker-owned relabel pallets and proves only new FBA carton marks are frozen', () => {
assert.match(source, /phase4OverseasRelabel/)
assert.match(source, /sourceStage === 'RELABEL'/)
assert.match(source, /relabel\.targetPalletIds\.includes/)
assert.match(source, /relabelCartonMarks/)
assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/)
assert.match(source, /relabelNo: relabel\.relabelNo/)
})
test('relabel settlement follows pre-arrange and does not replace the ordinary dispatch lifecycle', () => {
assert.match(source, /await runRelabelDispatch\(context, relabel\)[\s\S]*if \(relabel.dispatchNo\) await settleRelabelFulfillment\(context, relabel\)/)
const branch = source.slice(source.indexOf('const relabel = context.createdResources'), source.indexOf('const fixture = markerFixture(context)', source.indexOf('const relabel = context.createdResources')))
assert.doesNotMatch(branch, /\breturn\b/)
assert.match(source, /Object.assign\(relabel, \{/)
})
@@ -1,3 +1,5 @@
import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs'
const MODULE = 'phase4-overseas-dispatch'
const SCENARIO = 'phase4-overseas-dispatch-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -39,6 +41,49 @@ function markerFixture(context) {
return { ...fixture, palletIds }
}
async function runRelabelDispatch(context, relabel) {
const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选',
query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' }))
const candidates = (Array.isArray(candidateResult.data) ? candidateResult.data : [])
.filter((value) => value?.sourceStage === 'RELABEL'
&& relabel.targetPalletIds.includes(id(value?.warehousePalletId)))
const actualMarks = candidates.flatMap((value) => String(value?.relabelCartonMarks || '')
.split(',').map((mark) => mark.trim()).filter(Boolean))
context.assert(MODULE, '智能排车只带出本轮换标后的FBA箱唛', candidateResult.response,
candidates.length === relabel.targetPalletIds.length
&& relabel.relabelCartonMarks.every((mark) => actualMarks.includes(mark))
&& candidates.every((value) => !String(value?.cartonMarks || '').trim()),
`pallets=${candidates.length}, relabelMarks=${actualMarks.join(',')}, normalMarks=${candidates.map((value) => value?.cartonMarks).filter(Boolean).join(',')}; ${trace(candidateResult.response)}`,
OPTIONS)
if (candidates.length !== relabel.targetPalletIds.length) return
const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, {
planPalletIds: candidates.map((value) => id(value.warehousePalletId)),
route: `${context.marker}-RELABEL-ROUTE`, fbaAppointmentNo: null,
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo,
})
const dispatchNo = id(arranged.data?.dispatchNo)
context.assert(MODULE, '换标多托盘原子预排并冻结换标箱唛', arranged.response,
Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE'
&& Number(arranged.data?.palletCount) === candidates.length
&& Array.isArray(arranged.data?.items)
&& arranged.data.items.every((item) => item?.sourceStage === 'RELABEL'),
`dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`,
OPTIONS)
if (!dispatchNo) return
Object.assign(relabel, {
dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null,
relabelNo: relabel.relabelNo, planPalletId: null,
})
const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`)
const detailMarks = (detail.data?.items || []).flatMap((item) => String(item?.cartonMarks || '')
.split(',').map((mark) => mark.trim()).filter(Boolean))
context.assert(MODULE, '排车详情冻结全部换标后箱唛且不混入原箱唛', detail.response,
relabel.relabelCartonMarks.every((mark) => detailMarks.includes(mark))
&& relabel.sourceCartonMarks.every((mark) => !detailMarks.includes(mark)),
`frozenMarks=${detailMarks.join(',')}; ${trace(detail.response)}`, OPTIONS)
}
export async function run(context) {
context.createdResources.phase4OverseasDispatch = {
marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null,
@@ -53,6 +98,12 @@ export async function run(context) {
`records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS)
if (!success(initialPage.response)) return
const relabel = context.createdResources?.phase4OverseasRelabel
if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') {
await runRelabelDispatch(context, relabel)
if (relabel.dispatchNo) await settleRelabelFulfillment(context, relabel)
}
const fixture = markerFixture(context)
if (!fixture) {
blocker(context, '取得本轮海外PDA已收货托盘前置',
@@ -43,7 +43,7 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
'/api/pda/v1/last-leg/stocktakes',
'/api/pda/v1/last-leg/inventory-moves',
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve',
'/swap', '/disassemble', '/assemble', '/unbind',
'/swap', '/disassemble', '/assemble', '/unbind', '/relabel-pallet-label', '/relabel-disassemble',
'/api/pda/v1/last-leg/relabel-tasks',
'/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review',
]) assert.ok(source.includes(endpoint), endpoint)
@@ -52,15 +52,16 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:',
'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:',
'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map',
'const packageCount = 1', 'line.packageCount += additionalPackages', 'oldLabel: item.cartonMark',
'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark',
'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null',
'newLabel: item.fbaCartonMark || item.newSkuCode',
"operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:',
"deliveryMethod: 'TRUCK'", 'printShippingLabel: false',
'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) {
'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛', 'CUSTOMER_CONFIRM_PENDING', 'confirm-completion',
'P5库存流水记录换标拆托原因和正负变动', 'phase4OverseasRelabel']) {
assert.ok(source.includes(marker), marker)
}
for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
for (const forbidden of ['/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
assert.equal(source.includes(forbidden), false, forbidden)
}
assert.doesNotMatch(source, /fetch\(|https?:\/\//)
@@ -354,22 +354,17 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
for (const source of orderedCandidates) {
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
for (const position of source?.positions || []) {
if (!id(position?.positionId) || Number(position?.availablePackages) <= 0) continue
const packageCount = 1
sourceLines.push({ source, position, packageCount })
requiredPackages -= packageCount
const available = Number(position?.availablePackages)
if (!id(position?.positionId) || available <= 0) continue
const selected = Math.min(requiredPackages, available)
for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) {
sourceLines.push({ source, position, packageCount: 1 })
}
requiredPackages -= selected
if (requiredPackages === 0) break
}
if (requiredPackages === 0) break
}
for (const line of sourceLines) {
if (requiredPackages === 0) break
const remainingCapacity = Number(line.position.availablePackages) - line.packageCount
if (remainingCapacity <= 0) continue
const additionalPackages = Math.min(requiredPackages, remainingCapacity)
line.packageCount += additionalPackages
requiredPackages -= additionalPackages
}
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
requiredPackages === 0,
`sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS)
@@ -387,6 +382,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker,
deliveryMethod: 'TRUCK', printShippingLabel: false,
idempotencyKey: key(context, 'RELABEL-CREATE'),
deliveryMethod: 'TRUCK', printShippingLabel: false,
items: sourceLines.map(({ source, position, packageCount }, index) => {
const newSkuCode = `${source.skuCode}-NEW-${markerSuffix}-${index + 1}`.slice(0, 128)
const changesCartonLabel = nonBlank(source.destinationFbaWarehouseCode)
@@ -513,18 +509,140 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` }
const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody)
const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody)
context.assert(MODULE, 'P5仓库复核完成并进入客户确认', approveReplay.response,
approved.data?.taskStatus === 'COMPLETED'
&& approved.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
&& approveReplay.data?.taskStatus === 'COMPLETED'
&& approveReplay.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', trace(approveReplay.response), OPTIONS)
const customerConfirm = await context.requestOverseas('POST',
`${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, {
idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'), remark: `${context.marker}-customer-confirm`,
}, {}, merchantToken)
context.addStep(MODULE, 'P5客户确认换标完成进入待出库', customerConfirm,
success(customerConfirm) && customerConfirm.body?.data?.application?.status === 'READY_FOR_OUTBOUND',
trace(customerConfirm), OPTIONS)
context.assert(MODULE, 'P5 PDA批准后等待客户确认', approveReplay.response,
approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
&& approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS)
if (approved.data?.applicationStatus !== 'CUSTOMER_CONFIRM_PENDING') return false
const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'),
remark: `${context.marker}-customer-confirmed` }
const confirmedResponse = await context.requestOverseas('POST',
`${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken)
const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null
context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse,
confirmed?.application?.status === 'READY_FOR_OUTBOUND'
&& confirmed?.application?.fulfillmentStatus === 'WAIT_PRE_DISPATCH', trace(confirmedResponse), OPTIONS)
if (confirmed?.application?.status !== 'READY_FOR_OUTBOUND') return false
const operationBase = '/api/pda/v1/last-leg/pallet-operations'
const printKeyA = key(context, 'RELABEL-PRINT-A')
const printedA = await request(context, token, 'POST', 'P5打印换标新托盘A',
`${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA })
const printedAReplay = await request(context, token, 'POST', 'P5换标新托盘A打印同键重放',
`${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA })
context.assert(MODULE, 'P5新托盘标签幂等且返回可打印HTML', printedAReplay.response,
id(printedA.data?.palletId) === id(printedAReplay.data?.palletId)
&& printedA.data?.palletCode === printedAReplay.data?.palletCode
&& String(printedA.data?.printableHtml || '').includes(printedA.data?.palletCode || '__missing__'),
trace(printedAReplay.response), OPTIONS)
const printedB = await request(context, token, 'POST', 'P5打印换标新托盘B',
`${operationBase}/relabel-pallet-label`, {
relabelNo, idempotencyKey: key(context, 'RELABEL-PRINT-B'),
})
if (!printedA.data?.palletCode || !printedB.data?.palletCode) return false
const targets = [printedA.data, printedB.data]
const beforeBySource = new Map()
const movedFacts = []
for (const [index, itemDetail] of confirmed.items.entries()) {
const sourcePositionId = id(itemDetail?.sourceInventoryPositionId)
const sourceLine = sourceLines.find((line) => id(line.position.positionId) === sourcePositionId)
const sourcePalletCode = sourceLine?.position?.palletNo
if (!sourcePalletCode || !itemDetail?.cartonMark) {
blocker(context, 'P5换标拆托来源快照', `item=${index + 1}缺少来源托盘或箱唛`, confirmedResponse)
return false
}
const sourceBefore = (await resolvePallet(context, token, sourcePalletCode,
`P5拆托${index + 1}前刷新来源托盘`)).data
if (!sourceBefore) return false
if (!beforeBySource.has(sourcePalletCode)) beforeBySource.set(sourcePalletCode, Number(sourceBefore.totalPackages))
// A receives one carton, then B receives one and is reused as an occupied merge target.
// If the first two cartons share a source, the same source is split across A and B.
const targetSeed = index === 0 ? targets[0] : targets[1]
const targetBefore = (await resolvePallet(context, token, targetSeed.palletCode,
`P5拆托${index + 1}前刷新目标托盘`)).data
const reason = index === 0 ? null : `${context.marker}-场景${index + 1}`
const body = {
idempotencyKey: key(context, `RELABEL-DISASSEMBLE-${index + 1}`), relabelNo,
source: { palletCode: sourceBefore.palletCode, expectedVersion: Number(sourceBefore.palletVersion) },
target: { palletCode: targetBefore.palletCode, expectedVersion: Number(targetBefore.palletVersion) },
cartonMarks: [itemDetail.cartonMark], reason,
}
const moved = await request(context, token, 'POST', `P5换标箱${index + 1}拆托`,
`${operationBase}/relabel-disassemble`, body)
const replay = await request(context, token, 'POST', `P5换标箱${index + 1}拆托同键重放`,
`${operationBase}/relabel-disassemble`, body)
const sourceAfter = operationPallet(moved.data, sourceBefore.palletCode)
const targetAfter = operationPallet(moved.data, targetBefore.palletCode)
context.assert(MODULE, `P5换标拆托${index + 1}源减目标增且重放稳定`, replay.response,
moved.data?.operationType === 'RELABEL_PALLET_DISASSEMBLE' && replay.data?.replayed === true
&& Number(sourceAfter?.totalPackages) === Number(sourceBefore.totalPackages) - 1
&& Number(targetAfter?.totalPackages) === Number(targetBefore.totalPackages) + 1,
`source=${sourceBefore.totalPackages}->${sourceAfter?.totalPackages}, target=${targetBefore.totalPackages}->${targetAfter?.totalPackages}; ${trace(replay.response)}`,
OPTIONS)
if (!success(moved.response)) return false
movedFacts.push({ sourcePalletCode, targetPalletId: id(targetAfter?.palletId),
targetPalletCode: targetAfter?.palletCode, cartonMark: itemDetail.cartonMark,
fbaCartonMark: itemDetail.fbaCartonMark, balanceId: id(itemDetail.sourceInventoryBalanceId),
inboundOrderId: id(itemDetail.sourceInboundOrderId), inboundOrderNo: itemDetail.sourceInboundOrderNo,
inboundCode: itemDetail.sourceInboundCode, packages: Number(itemDetail.notifiedPackageCount), reason })
}
for (const [sourcePalletCode, totalBefore] of beforeBySource) {
const sourceAfter = (await resolvePallet(context, token, sourcePalletCode, 'P5拆托后核对来源托盘')).data
const movedPackages = movedFacts.filter((fact) => fact.sourcePalletCode === sourcePalletCode)
.reduce((sum, fact) => sum + fact.packages, 0)
context.assert(MODULE, 'P5来源托盘累计箱数按拆托批次减少', null,
Number(sourceAfter?.totalPackages) === totalBefore - movedPackages,
`pallet=${sourcePalletCode}, before=${totalBefore}, moved=${movedPackages}, after=${sourceAfter?.totalPackages}`, OPTIONS)
}
context.assert(MODULE, 'P5覆盖新托盘、既有托盘合托和同源分批拆托', null,
new Set(movedFacts.map((fact) => fact.targetPalletId)).size === 2
&& movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length >= 2
&& [...beforeBySource.keys()].some((source) => new Set(movedFacts.filter((fact) => fact.sourcePalletCode === source)
.map((fact) => fact.targetPalletId)).size >= 2),
`targets=${new Set(movedFacts.map((fact) => fact.targetPalletId)).size}, targetBMoves=${movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length}`, OPTIONS)
for (const fact of movedFacts) {
const positionsResponse = await context.requestOverseas('GET', query(
`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(fact.balanceId)}/positions`,
{ customerId }), undefined)
const positions = success(positionsResponse) && Array.isArray(positionsResponse.body?.data)
? positionsResponse.body.data : []
const targetPosition = positions.find((position) => id(position?.palletId) === fact.targetPalletId)
context.addStep(MODULE, `P5换标箱${fact.cartonMark}保留客户和原入仓归属`, positionsResponse,
id(targetPosition?.customerId) === customerId
&& id(targetPosition?.inboundOrderId) === fact.inboundOrderId
&& targetPosition?.inboundOrderNo === fact.inboundOrderNo
&& targetPosition?.inboundCode === fact.inboundCode,
`targetPallet=${fact.targetPalletCode}, inbound=${targetPosition?.inboundOrderNo || 'missing'}; ${trace(positionsResponse)}`, OPTIONS)
}
const ledgerResponse = await context.requestOverseas('GET', query(
'/api/platform/v1/last-leg/inventory/ledger', {
customerId, page: 1, size: 100, businessType: 'RELABEL_PALLET_DISASSEMBLE',
occurredFrom: new Date(Date.now() - 10 * 60_000).toISOString().slice(0, 19),
}))
const ledgerRows = success(ledgerResponse) ? records(ledgerResponse.body?.data) : []
const relabelLedgers = ledgerRows.filter((row) => movedFacts.some((fact) =>
row?.palletNo === fact.sourcePalletCode || row?.palletNo === fact.targetPalletCode))
context.addStep(MODULE, 'P5库存流水记录换标拆托原因和正负变动', ledgerResponse,
relabelLedgers.some((row) => Number(row?.deltaPackages) < 0)
&& relabelLedgers.some((row) => Number(row?.deltaPackages) > 0)
&& relabelLedgers.every((row) => String(row?.remark || '').startsWith('换标拆托')),
`rows=${relabelLedgers.length}, deltas=${relabelLedgers.map((row) => row.deltaPackages).join(',')}; ${trace(ledgerResponse)}`, OPTIONS)
context.createdResources.phase4OverseasRelabel = {
marker: context.marker, relabelNo, customerId, overseasWarehouseId: id(fixture.overseasWarehouseId),
targetPalletIds: [...new Set(movedFacts.map((fact) => fact.targetPalletId))],
relabelCartonMarks: movedFacts.map((fact) => fact.fbaCartonMark).filter(nonBlank),
sourceCartonMarks: movedFacts.map((fact) => fact.cartonMark), status: 'READY_FOR_OUTBOUND',
}
return success(approved.response)
}
export async function settleRelabelFulfillment(context, relabel) {
const relabelNo = relabel.relabelNo
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
const terminal = await context.requestOverseas('PUT',
`${relabelBase}/${encodeURIComponent(relabelNo)}/status`, {
status: 'COMPLETED', remark: `${context.marker}-fulfillment-complete`,
@@ -536,7 +654,8 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result,
settled.result?.body?.data?.application?.walletStatus === 'SETTLED',
`pollRequests=${settled.attempts}, walletStatus=${settled.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(settled.result)}`, OPTIONS)
return success(approved.response) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED'
relabel.status = terminal.body?.data?.application?.status || relabel.status
return success(terminal) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED'
}
export async function run(context) {
@@ -1,7 +1,8 @@
-- 尾程贴标申请 / 退货换标 dev 增量(MySQL 8.0)
-- 目标数据库:baiyue_lastleg
-- 依据:2026-09-09 本地 127.0.0.1 与 dev 8.163.49.32 的 information_schema 只读对比。
-- 说明:脚本可重复执行,不删除或修改业务行;DDL 会隐式提交,请先备份并完整执行。
-- 目标数据库:baiyue_lastleg、baiyue_lastlag_warehouse
-- 依据:2026-09-10 本地 127.0.0.1 与 dev 8.163.49.32 的 information_schema 只读对比。
-- 说明:脚本可重复执行,不修改或删除业务行。
-- MySQL DDL 会隐式提交,请先备份并完整执行。
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -88,9 +89,141 @@ DELIMITER ;
CALL _dev_lastleg_relabel_return_patch();
DROP PROCEDURE IF EXISTS _dev_lastleg_relabel_return_patch;
-- 执行后只读核验:应分别返回 1、1、1、1。
-- 2026-09-10 本地相对 dev 的最近结构差异:换标完成确认、退货仓联系人及派车来源快照。
DROP PROCEDURE IF EXISTS _dev_lastleg_recent_patch;
DELIMITER $$
CREATE PROCEDURE _dev_lastleg_recent_patch()
BEGIN
-- 旧本地库可能缺少仓库主数据与退货列表查询字段。
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_warehouse' AND table_name='wh_warehouse' AND column_name='internal_code') THEN
SET @ddl = 'ALTER TABLE baiyue_warehouse.wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code';
PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no') THEN
ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT '关联尾程订单号' AFTER lastleg_order_id;
END IF;
-- 非 FBA 去向的换标不要求货件编号。
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='shipment_no' AND is_nullable='NO') THEN
ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_code' AND is_nullable='NO') THEN
ALTER TABLE ll_relabel_item MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码(组合换标时可为空)';
END IF;
-- 换标 PDA 完工、客户证据确认,以及卡车/自提/快递履约分流。
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method') THEN
ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填' AFTER contact_phone;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label') THEN
ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单' AFTER delivery_method;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id') THEN
ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID' AFTER print_shipping_label;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name') THEN
ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名' AFTER shipping_label_file_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url') THEN
ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址' AFTER shipping_label_file_name;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status') THEN
ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态' AFTER shipping_label_file_url;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key') THEN
ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键' AFTER fulfillment_status;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at') THEN
ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间' AFTER customer_confirmation_key;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no') THEN
ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号' AFTER fulfillment_status;
END IF;
-- 退货换标:保存操作中转仓联系人。
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name') THEN
ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照' AFTER overseas_warehouse_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone') THEN
ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照' AFTER warehouse_contact_name;
END IF;
-- 仓储转运允许非 FBA 入仓。
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO') THEN
ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_shipment_no' AND is_nullable='NO') THEN
ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号';
END IF;
-- 换标进入派车/出库后保留来源业务和箱唛快照。
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks') THEN
ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照' AFTER source_stage;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type') THEN
ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT '来源业务类型' AFTER carton_marks;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no') THEN
ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT '来源业务单号' AFTER source_business_type;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='return_carton_id') THEN
ALTER TABLE ll_relabel_item ADD COLUMN return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID' AFTER carton_mark;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_carton_mark') THEN
ALTER TABLE ll_relabel_item ADD COLUMN source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照' AFTER return_carton_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='new_carton_mark') THEN
ALTER TABLE ll_relabel_item ADD COLUMN new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛' AFTER source_carton_mark;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at') THEN
ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT '关联贴标完成时间' AFTER relabel_no;
END IF;
END$$
DELIMITER ;
CALL _dev_lastleg_recent_patch();
DROP PROCEDURE IF EXISTS _dev_lastleg_recent_patch;
-- 2026-09-13 换标/合托按托盘维度作业:海外仓物理托盘允许没有库位。
-- 只放宽托盘状态操作审计快照,不改变已有带库位的入仓资源预留逻辑。
CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_lastlag_warehouse;
DROP PROCEDURE IF EXISTS _dev_locationless_pallet_patch;
DELIMITER $$
CREATE PROCEDURE _dev_locationless_pallet_patch()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 wh_pallet_operation,请先执行海外仓基线';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation'
AND column_name='before_location_id' AND is_nullable='NO') THEN
ALTER TABLE wh_pallet_operation
MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation'
AND column_name='after_location_id' AND is_nullable='NO') THEN
ALTER TABLE wh_pallet_operation
MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空';
END IF;
END$$
DELIMITER ;
CALL _dev_locationless_pallet_patch();
DROP PROCEDURE IF EXISTS _dev_locationless_pallet_patch;
USE baiyue_lastleg;
-- 执行后只读核验:所有结果都应为 1。
SELECT
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('destination_fba_warehouse_code','operation_type','sku_relabel_items_json','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id','source_inbound_order_no','source_inbound_code','source_warehouse_id','source_warehouse_code','source_warehouse_name')) = 11 AS relabel_fields_ready,
(SELECT is_nullable='YES' FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name='shipment_no') AS relabel_shipment_optional_ready,
(SELECT is_nullable='YES' FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name='sku_code') AS relabel_sku_optional_ready,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name IN ('delivery_method','print_shipping_label','shipping_label_file_id','shipping_label_file_name','shipping_label_file_url','fulfillment_status','outbound_order_no','customer_confirmation_key','customer_confirmed_at')) = 9 AS relabel_fulfillment_ready,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name IN ('warehouse_contact_name','warehouse_contact_phone')) = 2 AS return_order_contact_fields_ready,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_warehouse_transfer_order' AND column_name IN ('fba_warehouse_code','fba_shipment_no') AND is_nullable='YES') = 2 AS transfer_fba_optional_ready,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_dispatch_item' AND column_name IN ('carton_marks','source_business_type','source_business_no')) = 3 AS dispatch_relabel_source_ready,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('return_carton_id','source_carton_mark','new_carton_mark')) = 3 AS return_relabel_binding_ready,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks') = 1 AS outbound_carton_marks_ready,
(SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_overseas_procurement_outbound') = 1 AS procurement_outbound_ready,
(SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')) = 2 AS return_outbounded_checks_ready;
(SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')) = 2 AS return_outbounded_checks_ready,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation' AND column_name IN ('before_location_id','after_location_id') AND is_nullable='YES') = 2 AS locationless_pallet_operation_ready;
+34
View File
@@ -0,0 +1,34 @@
-- 修复海外仓筛选名称、退货箱号和贴标申请列表 503。
-- 可重复执行;请使用具备对应库 ALTER 权限的账号执行。
USE baiyue_warehouse;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'wh_warehouse'
AND column_name = 'internal_code'),
'SELECT 1',
'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
USE baiyue_lastleg;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_return_order'
AND column_name = 'lastleg_order_no'),
'SELECT 1',
'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_warehouse' AND table_name = 'wh_warehouse' AND column_name = 'internal_code') AS warehouse_internal_code_ready,
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_return_order' AND column_name = 'lastleg_order_no') AS return_lastleg_order_no_ready;
+41
View File
@@ -4035,6 +4035,8 @@ USE baiyue_lastleg;
-- 2026-09-09 / 036 为派送单补充 BOL 号字段(不改原始 CREATE TABLE)。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL COMMENT ''派送资料BOL号,不作为文件归属标识'''));
PREPARE ll_delivery_order_bol_no_stmt FROM @ddl; EXECUTE ll_delivery_order_bol_no_stmt; DEALLOCATE PREPARE ll_delivery_order_bol_no_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='documents_json'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN documents_json JSON NULL COMMENT ''派送三类资料当前绑定及权威文件元数据快照'' AFTER bol_no'));
PREPARE ll_delivery_order_documents_json_stmt FROM @ddl; EXECUTE ll_delivery_order_documents_json_stmt; DEALLOCATE PREPARE ll_delivery_order_documents_json_stmt;
SET @ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'),
'SELECT 1',
@@ -4156,3 +4158,42 @@ SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)',
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)'));
PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt;
-- 2026-09-14 / 038 海外尾程按托盘维度作业,无库位托盘的状态流转审计允许空库位。
USE baiyue_lastlag_warehouse;
ALTER TABLE wh_pallet_operation
MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空',
MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空';
USE baiyue_lastleg;
-- 2026-09-10 / 036 补齐仓库候选与退货列表查询依赖的字段,修复旧库升级后的 503。
USE baiyue_warehouse;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='internal_code'), 'SELECT 1', 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code'));
PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt;
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id'));
PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_stmt; DEALLOCATE PREPARE return_order_lastleg_no_stmt;
-- 2026-09-11 / 037 仓储转运 PDA 打托与换标预绑定事实。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet (
id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码',
warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID',
carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM',
status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', 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_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID',
transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型',
customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数',
fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细';
@@ -1,5 +1,21 @@
-- 本地验收数据:为 17909216591 对应客户建立 3 张已完成入库的一件代发订单。
-- 可重复执行;候选数据覆盖订单、入库计划、托盘、库存余额/仓位、入库实收、一件代发档案、SKU 与箱唛。
-- 可重复执行;候选数据覆盖海外实物托盘、订单、入库计划、库存余额/仓位、入库实收、一件代发档案、SKU 与箱唛。
USE baiyue_lastlag_warehouse;
INSERT INTO wh_pallet (
id,customer_id,warehouse_id,location_id,pallet_code,pallet_type,capacity_cbm,unit_price,
status,remark,fba_warehouse_code,created_by,created_at,updated_by,updated_at,deleted,version
) VALUES
(2191000000000909901,0,193000000000200003,NULL,'DS-LAX-PALLET-01','STANDARD',2.0000,0.00,
'OCCUPIED','一件代发本地验收固定托盘','ONT8',1000001,NOW(3),1000001,NOW(3),0,0),
(2191000000000909902,0,193000000000200003,NULL,'DS-LAX-PALLET-02','STANDARD',2.0000,0.00,
'OCCUPIED','一件代发本地验收固定托盘','ABE2',1000001,NOW(3),1000001,NOW(3),0,0),
(2191000000000909903,0,193000000000200003,NULL,'DS-LAX-PALLET-03','STANDARD',2.0000,0.00,
'OCCUPIED','一件代发本地验收固定托盘','ABE3',1000001,NOW(3),1000001,NOW(3),0,0)
ON DUPLICATE KEY UPDATE
warehouse_id=VALUES(warehouse_id),pallet_code=VALUES(pallet_code),pallet_type=VALUES(pallet_type),
status=VALUES(status),fba_warehouse_code=VALUES(fba_warehouse_code),deleted=0,updated_at=NOW(3);
USE baiyue_lastleg;
DROP TEMPORARY TABLE IF EXISTS _tmp_dropship_fixture;
+1 -1
View File
@@ -11,7 +11,7 @@
"services": 15
},
"openapi": {
"documents": 46,
"documents": 48,
"coveredServices": 10,
"codeFirstServices": 5
},
+1 -1
View File
@@ -65,7 +65,7 @@ test('repository freezes the current topology and non-blocking aggregate summary
const summary = await buildQualitySummary(repositoryRoot)
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 })
assert.equal(summary.openapi.documents, 46)
assert.equal(summary.openapi.documents, 48)
assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml'))
assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml'))
assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml'))
@@ -0,0 +1,111 @@
import { readFile, writeFile } from 'node:fs/promises'
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
const sourceReport = process.env.OVERSEAS_ACCEPTANCE_SOURCE
const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO
const directRelabelNo = process.env.OVERSEAS_RELABEL_NO
const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json'
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
const required = ['ADMIN_TEST_USERNAME', 'ADMIN_TEST_PASSWORD', 'PDA_TEST_USERNAME', 'PDA_TEST_PASSWORD']
for (const name of required) if (!process.env[name]) throw new Error(`Missing ${name}`)
if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id')
function id(value) { return value == null ? '' : String(value) }
function key(value) { return `${marker}-${value}`.slice(0, 200) }
function assert(value, message) { if (!value) throw new Error(message) }
async function api(base, token, method, path, body) {
const response = await fetch(`${base}${path}`, { method,
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body) })
const text = await response.text(); let value
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
if (!response.ok || String(value?.code) !== '200') {
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
}
return value.data
}
const source = sourceReport ? JSON.parse(await readFile(sourceReport, 'utf8')) : null
const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TARGETS')
|| { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' }
assert(scenario?.dispatchNo, 'Dispatch not found in source report')
const dispatchNo = scenario.dispatchNo
const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', {
loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })
const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', {
loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD })
const ticket = await api(domestic, platformLogin.accessToken, 'POST',
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` })
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
const platformToken = platformLogin.overseasAccessToken
const merchantToken = merchantLogin.overseasAccessToken
const pdaToken = pdaLogin.overseasAccessToken
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000',
carrierName: '尾程验收车队', route: `${marker}-ROUTE`,
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(),
})
const transfer = await api(overseas, merchantToken, 'POST',
`/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, {
idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(),
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队',
pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: 'MEDIUM',
driverName: '尾程验收司机', driverPhone: '19900000000', vehiclePlate: `UAT-${marker.slice(-8)}`,
appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费',
direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }],
})
assert(transfer.outboundNo && transfer.deliveryNo, 'Transfer did not create outbound and delivery')
let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items')
for (const [itemIndex, item] of task.items.entries()) {
let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
for (const [positionIndex, position] of item.positions.entries()) {
if (remaining <= 0) break
const packages = Math.min(remaining, Number(position.availablePackages))
if (packages <= 0) continue
await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, {
positionId: id(position.id), packages, productQuantity: packages,
idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker,
})
remaining -= packages
}
assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`)
}
const completed = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') })
assert(completed.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART')
const departed = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker })
assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED')
const outbound = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`)
assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED')
let delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`)
const delivering = await api(overseas, platformToken, 'PUT',
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/status?customerId=${customerId}`, {
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
})
assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING')
delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`)
const delivered = await api(overseas, platformToken, 'POST',
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/complete?customerId=${customerId}`, {
status: 'DELIVERED', expectedVersion: Number(delivery.order.version), remark: marker,
})
assert(delivered.deliveryStatus === 'DELIVERED', 'Delivery did not reach DELIVERED')
const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo,
dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo,
outboundStatus: outbound.outbound.status, deliveryNo: transfer.deliveryNo,
deliveryStatus: delivered.deliveryStatus, visitId: id(delivered.visitId), shippedPackages: departed.shippedPackages,
passed: true, finishedAt: new Date().toISOString() }
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
console.log(JSON.stringify(report, null, 2))
@@ -0,0 +1,193 @@
import { writeFile } from 'node:fs/promises'
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
const platformUser = process.env.ADMIN_TEST_USERNAME
const platformPassword = process.env.ADMIN_TEST_PASSWORD
const pdaUser = process.env.PDA_TEST_USERNAME
const pdaPassword = process.env.PDA_TEST_PASSWORD
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID
const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json'
const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
for (const [name, value] of Object.entries({ platformUser, platformPassword, pdaUser, pdaPassword, customerId, warehouseId })) {
if (!value) throw new Error(`Missing environment variable: ${name}`)
}
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false }
function key(value) { return `${marker}-${value}`.slice(0, 200) }
function id(value) { return value == null ? '' : String(value) }
async function api(base, token, method, path, body, headers = {}) {
const response = await fetch(`${base}${path}`, {
method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers },
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
})
const text = await response.text()
let value
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
if (!response.ok || String(value?.code) !== '200') {
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
}
return value.data
}
async function poll(action, predicate, attempts = 40) {
for (let index = 0; index < attempts; index += 1) {
const value = await action()
if (predicate(value)) return value
await new Promise((resolve) => setTimeout(resolve, 500))
}
throw new Error('Polling timed out')
}
function assert(condition, message) { if (!condition) throw new Error(message) }
const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword })
const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword })
const ticket = await api(domestic, platformLogin.accessToken, 'POST',
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` })
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
const platformToken = platformLogin.overseasAccessToken
const merchantToken = merchantLogin.overseasAccessToken
const pdaToken = pdaLogin.overseasAccessToken
assert(platformToken && merchantToken && pdaToken, 'Missing overseas token')
const candidates = await api(overseas, merchantToken, 'GET',
`/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`)
const source = candidates.find((item) => String(item.inboundOrderNo || '').startsWith('DS-')
&& Number(item.availablePackages) >= 5
&& item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))
assert(source, 'No overseas inventory source with at least five available cartons')
const sourcePosition = source.positions.find((position) => Number(position.availablePackages) >= 5)
async function resolvePallet(palletCode) {
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
}
async function createAndApprove(name, cartonCount) {
const suffix = `${name}-${Date.now()}`
const items = Array.from({ length: cartonCount }, (_, index) => ({
sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId),
sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode,
productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1,
shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128),
labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`,
fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode,
newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }],
}))
const created = await api(overseas, platformToken, 'POST',
`/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, {
customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId,
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01,
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK',
printShippingLabel: false, items,
})
const relabelNo = created.application.relabelNo
await poll(() => api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/relabel-applications/${relabelNo}`),
(detail) => detail.application.walletStatus === 'FROZEN')
let detail = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) })
for (const [index, item] of detail.items.entries()) {
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark,
expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
})
}
const form = new FormData()
form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`)
const photo = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form)
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version),
idempotencyKey: key(`${name}-COMPLETE`), remark: marker,
})
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, {
decision: 'APPROVE', expectedVersion: Number(detail.version),
idempotencyKey: key(`${name}-APPROVE`), remark: marker,
})
assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`)
const confirmed = await api(overseas, merchantToken, 'POST',
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
idempotencyKey: key(`${name}-CONFIRM`), remark: marker,
})
assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`)
return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) }
}
async function printPallet(relabelNo, suffix) {
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
relabelNo, idempotencyKey: key(`PRINT-${suffix}`),
})
}
async function moveCarton(scenario, item, target, index) {
const sourcePallet = await resolvePallet(sourcePosition.palletNo)
const targetPallet = await resolvePallet(target.palletCode)
const result = await api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo,
source: { palletCode: sourcePallet.palletCode, expectedVersion: Number(sourcePallet.palletVersion) },
target: { palletCode: targetPallet.palletCode, expectedVersion: Number(targetPallet.palletVersion) },
cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`,
})
assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type')
return result
}
const fresh = await createAndApprove('NEW-PALLET', 1)
const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED')
const printReplay = await printPallet(fresh.relabelNo, 'SHARED')
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
await moveCarton(fresh, fresh.items[0], sharedTarget, 0)
fresh.targets = [sharedTarget]
const merged = await createAndApprove('MERGE-EXISTING', 1)
const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
await moveCarton(merged, merged.items[0], sharedTarget, 0)
const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase')
merged.targets = [sharedTarget]
const split = await createAndApprove('SPLIT-TWO-TARGETS', 2)
const splitA = await printPallet(split.relabelNo, 'SPLIT-A')
const splitB = await printPallet(split.relabelNo, 'SPLIT-B')
await moveCarton(split, split.items[0], splitA, 0)
await moveCarton(split, split.items[1], splitB, 1)
split.targets = [splitA, splitB]
for (const scenario of [fresh, merged, split]) {
const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
scenario.sourceAfter = sourceAfter
scenario.targetPallets = scenario.targets.map((target) => ({ palletId: id(target.palletId), palletCode: target.palletCode }))
const candidateRows = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(scenario.relabelNo)}&sourceStage=RECEIVED`)
const targetIds = new Set(scenario.targets.map((target) => id(target.palletId)))
const rows = candidateRows.filter((row) => row.sourceStage === 'RELABEL' && targetIds.has(id(row.warehousePalletId)))
const marks = rows.flatMap((row) => String(row.relabelCartonMarks || '').split(',').filter(Boolean))
assert(rows.length === targetIds.size, `${scenario.relabelNo} missing smart-dispatch pallet candidate`)
assert(rows.every((row) => !String(row.cartonMarks || '').trim()), `${scenario.relabelNo} exposed normal carton marks`)
assert(scenario.items.every((item) => marks.includes(item.fbaCartonMark)), `${scenario.relabelNo} missing relabel carton mark`)
const dispatch = await api(overseas, platformToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', {
planPalletIds: rows.map((row) => id(row.warehousePalletId)), route: `${marker}-${scenario.name}`,
relabelNo: scenario.relabelNo,
})
scenario.dispatchNo = dispatch.dispatchNo
scenario.dispatchStatus = dispatch.status
scenario.relabelCartonMarks = marks
delete scenario.items
delete scenario.targets
}
const ledger = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`)
const ledgerRows = ledger.records.filter((row) => String(row.remark || '').includes(marker))
assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger')
assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger')
assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason')
report.scenarios = [fresh, merged, split]
report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) }
report.passed = true
report.finishedAt = new Date().toISOString()
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
console.log(JSON.stringify(report, null, 2))
@@ -15,6 +15,8 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({
})
export const SYNCHRONOUS_CLIENTS = Object.freeze([
feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java',
'lastleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '根据已授权排车快照生成 BOL PDF,内部令牌保护,无业务状态写入'),
feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java',
'auth-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 读取授权快照,不写 IAM'),
feign('auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java',
@@ -34,6 +34,7 @@ const expectedOnlineEdges = [
'iam-service->auth-service',
'iam-service->warehouse-service',
'lastleg-service->file-service',
'lastleg-service->print-service',
'lastleg-service->warehouse-overseas-service',
'lastleg-service->warehouse-service',
'order-service->file-service',
@@ -99,11 +100,11 @@ async function discoverFeignEdges() {
return clients.sort((left, right) => left.path.localeCompare(right.path))
}
test('all 57 Feign clients have reviewed context, access and idempotency metadata', async () => {
test('all 58 Feign clients have reviewed context, access and idempotency metadata', async () => {
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
.map(({ path, source, target }) => ({ path, source, target }))
.sort((left, right) => left.path.localeCompare(right.path))
assert.equal(reviewed.length, 57)
assert.equal(reviewed.length, 58)
assert.deepEqual(await discoverFeignEdges(), reviewed)
})
@@ -2,6 +2,7 @@ package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.DropshipPalletRequests;
import com.baiyue.warehouse.dto.RelabelPalletRequests;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.dto.WarehousePalletResolveResponse;
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
@@ -24,6 +25,14 @@ public class InternalWarehousePalletController {
return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId()));
}
@PostMapping("/ensure-relabel")
public ApiResponse<WarehousePalletResolveResponse> ensureRelabel(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody RelabelPalletRequests.Ensure request) {
tokenGuard.verify(token);
return ApiResponse.success(service.ensureRelabelPallet(request.warehouseId(), request.businessId()));
}
@GetMapping("/resolve/{palletCode}")
public ApiResponse<WarehousePalletResolveResponse> resolve(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -1,7 +1,6 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
@@ -12,7 +11,7 @@ public final class PalletTransitionRequests {
public record Apply(@Positive long warehouseId,
@PositiveOrZero int expectedVersion,
@NotBlank @Size(max = 20) String targetStatus,
@NotNull @Positive Long targetLocationId,
@Positive Long targetLocationId,
@NotBlank @Size(max = 128) String operationKey,
@Size(max = 500) String reason) { }
@@ -7,8 +7,8 @@ public record PalletTransitionResponse(
long warehouseId,
String beforeStatus,
String afterStatus,
long beforeLocationId,
long afterLocationId,
Long beforeLocationId,
Long afterLocationId,
int beforeVersion,
int afterVersion,
String operationStatus,
@@ -0,0 +1,12 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public final class RelabelPalletRequests {
private RelabelPalletRequests() { }
public record Ensure(@Positive long warehouseId,
@NotBlank @Size(max = 128) String businessId) { }
}
@@ -4,7 +4,7 @@ public record WarehousePalletResolveResponse(
long palletId,
String palletCode,
long warehouseId,
long locationId,
Long locationId,
String status,
String fbaWarehouseCode,
java.math.BigDecimal unitPrice,
@@ -14,13 +14,13 @@ public record WarehousePalletResolveResponse(
boolean reservable,
String reservationBlockReason
) {
public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId,
public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, Long locationId,
String status, String fbaWarehouseCode, int version) {
this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode,
java.math.BigDecimal.ZERO, version, null, null, false, "LOCATION_STATE_UNKNOWN");
}
public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId,
public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, Long locationId,
String status, String fbaWarehouseCode,
java.math.BigDecimal unitPrice, int version) {
this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode,
@@ -39,6 +39,6 @@ public interface WarehousePalletTransitionMapper extends BaseMapper<WarehousePal
"updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " +
"AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0")
int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId,
@Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId,
@Param("targetStatus") String targetStatus, @Param("targetLocationId") Long targetLocationId,
@Param("expectedVersion") int expectedVersion);
}
@@ -45,16 +45,17 @@ public class WarehousePalletTransitionService {
if (version != request.expectedVersion()) {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
}
if (pallet.getLocationId() == null) {
throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业");
}
transition(pallet, status, request.targetLocationId(), version);
// 当前海外尾程按托盘作业,不要求业务端选择库位。未传目标库位时保留托盘现有
// 技术库位;历史无库位托盘则只流转托盘状态,不能因此阻断 PDA 作业。
Long targetLocationId = request.targetLocationId() == null
? pallet.getLocationId() : request.targetLocationId();
transition(pallet, status, targetLocationId, version);
WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity();
operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash);
operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId);
operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus());
operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId());
operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version);
operation.setAfterLocationId(targetLocationId); operation.setBeforeVersion(version);
operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED");
operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now());
operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation);
@@ -75,7 +76,7 @@ public class WarehousePalletTransitionService {
}
WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId);
if (pallet == null || !operation.getAfterStatus().equals(pallet.getStatus())
|| !Long.valueOf(operation.getAfterLocationId()).equals(pallet.getLocationId())) {
|| !java.util.Objects.equals(operation.getAfterLocationId(), pallet.getLocationId())) {
throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败");
}
transition(pallet, operation.getBeforeStatus(), operation.getBeforeLocationId(),
@@ -85,14 +86,18 @@ public class WarehousePalletTransitionService {
return response(operation, false);
}
private void transition(WarehousePalletEntity pallet, String targetStatus, long targetLocationId,
private void transition(WarehousePalletEntity pallet, String targetStatus, Long targetLocationId,
int expectedVersion) {
int currentVersion = pallet.getVersion() == null ? 0 : pallet.getVersion();
if (currentVersion != expectedVersion || !MUTABLE_STATUSES.contains(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "托盘版本或状态已变化,请重新扫码");
}
long warehouseId = pallet.getWarehouseId();
long sourceLocationId = pallet.getLocationId();
Long sourceLocationId = pallet.getLocationId();
if (sourceLocationId == null || targetLocationId == null) {
transitionWithOptionalLocation(pallet, sourceLocationId, targetLocationId, targetStatus, expectedVersion);
return;
}
WarehouseLocationEntity source;
WarehouseLocationEntity target;
if (sourceLocationId <= targetLocationId) {
@@ -116,6 +121,25 @@ public class WarehousePalletTransitionService {
}
}
private void transitionWithOptionalLocation(WarehousePalletEntity pallet, Long sourceLocationId,
Long targetLocationId, String targetStatus,
int expectedVersion) {
long warehouseId = pallet.getWarehouseId();
if (sourceLocationId != null) {
WarehouseLocationEntity source = requireLocation(warehouseId, sourceLocationId);
requireLocationMatchesPallet(source, pallet.getStatus());
releaseSourceLocation(warehouseId, source, pallet.getStatus());
}
if (targetLocationId != null) {
WarehouseLocationEntity target = requireLocation(warehouseId, targetLocationId);
requireAvailableTarget(target, targetStatus);
occupyTargetLocation(warehouseId, target, targetStatus);
}
if (mapper.transition(warehouseId, pallet.getId(), targetStatus, targetLocationId, expectedVersion) != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
}
}
private WarehouseLocationEntity requireLocation(long warehouseId, long locationId) {
WarehouseLocationEntity location = mapper.lockPublicLocation(warehouseId, locationId);
if (location == null || "INACTIVE".equals(location.getStatus())) {
@@ -116,6 +116,30 @@ public class WarehouseResourceService {
}
}
@Transactional
public WarehousePalletResolveResponse ensureRelabelPallet(long warehouseId, String businessId) {
String normalizedBusinessId = businessId == null ? null : businessId.trim();
WarehousePalletBindingEntity existing = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId,
"RELABEL_PDA_NEW", normalizedBusinessId);
if (existing != null) return resolvePublicPallet(existing.getPalletId());
Long palletId = createPallet(PUBLIC_RESOURCE_SCOPE, warehouseId,
new PalletRequests.SaveRequest(null, "STANDARD", null, java.math.BigDecimal.ZERO, "IDLE", null,
"PDA换标拆托新托盘"));
WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity();
binding.setId(IdWorker.getId()); binding.setCustomerId(PUBLIC_RESOURCE_SCOPE); binding.setWarehouseId(warehouseId);
binding.setBusinessType("RELABEL_PDA_NEW"); binding.setBusinessId(normalizedBusinessId); binding.setPalletId(palletId);
binding.setCreatedAt(java.time.LocalDateTime.now());
try {
repository.insertPalletBinding(binding);
return resolvePublicPallet(palletId);
} catch (org.springframework.dao.DuplicateKeyException duplicate) {
WarehousePalletBindingEntity winner = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId,
"RELABEL_PDA_NEW", normalizedBusinessId);
if (winner == null) throw duplicate;
return resolvePublicPallet(winner.getPalletId());
}
}
/**
* Returns the warehouse-scoped virtual location used only as a pallet/inventory reference.
* It is deliberately never made IDLE/OCCUPIED by resource locking.
@@ -186,7 +210,6 @@ public class WarehouseResourceService {
if (matches.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "托盘码不存在");
if (matches.size() != 1) throw new BizException(ResultCode.CONFLICT, "托盘码在多个仓库重复,无法解析");
WarehousePalletEntity pallet = matches.get(0);
if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位");
if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废");
return palletReference(pallet);
}
@@ -200,7 +223,6 @@ public class WarehouseResourceService {
public WarehousePalletResolveResponse resolvePublicPallet(long palletId) {
WarehousePalletEntity pallet = repository.findPublicPallet(palletId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在");
if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位");
return palletReference(pallet);
}
@@ -224,8 +246,8 @@ public class WarehouseResourceService {
}
private WarehousePalletResolveResponse palletReference(WarehousePalletEntity pallet) {
WarehouseLocationEntity location = repository.findLocation(PUBLIC_RESOURCE_SCOPE,
pallet.getWarehouseId(), pallet.getLocationId());
WarehouseLocationEntity location = pallet.getLocationId() == null ? null
: repository.findLocation(PUBLIC_RESOURCE_SCOPE, pallet.getWarehouseId(), pallet.getLocationId());
String blockReason = reservationBlockReason(pallet, location);
return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(),
pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), pallet.getUnitPrice(),
@@ -236,7 +258,7 @@ public class WarehouseResourceService {
private static String reservationBlockReason(WarehousePalletEntity pallet, WarehouseLocationEntity location) {
if (!"IDLE".equals(pallet.getStatus())) return "PALLET_NOT_IDLE";
if (location == null) return "LOCATION_NOT_FOUND";
if (location == null) return pallet.getLocationId() == null ? "LOCATION_NOT_REQUIRED" : "LOCATION_NOT_FOUND";
if ("VIRTUAL".equals(location.getLocationType())) {
return "ACTIVE".equals(location.getStatus()) ? null : "VIRTUAL_LOCATION_NOT_ACTIVE";
}
@@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation (
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空',
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -123,6 +123,10 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation (
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
ALTER TABLE wh_pallet_operation
MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空',
MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空';
CREATE TABLE IF NOT EXISTS wh_pallet_batch (
id BIGINT NOT NULL COMMENT '批量托盘预留ID',
warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',

Some files were not shown because too many files have changed in this diff Show More