Merge origin/dev into test preserving warehouse and PDA workflows
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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$$
|
||||
|
||||
+6
-1
@@ -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");
|
||||
|
||||
+7
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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);
|
||||
}
|
||||
|
||||
+26
-5
@@ -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; }
|
||||
|
||||
+11
@@ -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")
|
||||
|
||||
+3
-1
@@ -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));
|
||||
}
|
||||
|
||||
+48
-7
@@ -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 > 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 > 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);
|
||||
}
|
||||
|
||||
+7
-1
@@ -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")
|
||||
|
||||
+12
@@ -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' " +
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+4
-3
@@ -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.
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+25
-2
@@ -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());
|
||||
|
||||
+13
@@ -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) {
|
||||
|
||||
+133
-31
@@ -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<>'' " +
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
+18
-2
@@ -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()
|
||||
|
||||
+5
-4
@@ -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) { }
|
||||
}
|
||||
|
||||
+33
-5
@@ -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) { }
|
||||
|
||||
+7
@@ -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)); }
|
||||
|
||||
+20
@@ -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;
|
||||
}
|
||||
|
||||
+8
@@ -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
@@ -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);
|
||||
|
||||
+119
-3
@@ -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) {
|
||||
|
||||
+9
@@ -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");
|
||||
|
||||
+4
@@ -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) {}
|
||||
}
|
||||
|
||||
+4
@@ -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) { }
|
||||
}
|
||||
|
||||
+1
@@ -36,4 +36,5 @@ public class ReturnInventoryCarton {
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime mergedAt;
|
||||
private String newCartonMark;
|
||||
}
|
||||
|
||||
+41
-1
@@ -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("合箱请求正在处理");
|
||||
|
||||
+67
@@ -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(),
|
||||
|
||||
+7
@@ -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) {
|
||||
|
||||
+43
@@ -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));
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -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)) {
|
||||
|
||||
+49
-3
@@ -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,
|
||||
|
||||
+19
-5
@@ -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) {}
|
||||
}
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+12
@@ -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;
|
||||
|
||||
+33
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
|
||||
+16
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+466
-9
@@ -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]
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+17
-1
@@ -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;
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+33
-6
@@ -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();
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+28
-7
@@ -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 {
|
||||
|
||||
+29
@@ -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);
|
||||
|
||||
+35
@@ -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 + "')");
|
||||
}
|
||||
}
|
||||
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"services": 15
|
||||
},
|
||||
"openapi": {
|
||||
"documents": 46,
|
||||
"documents": 48,
|
||||
"coveredServices": 10,
|
||||
"codeFirstServices": 5
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
+9
@@ -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
-2
@@ -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) { }
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+12
@@ -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) { }
|
||||
}
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+32
-8
@@ -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())) {
|
||||
|
||||
+27
-5
@@ -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
Reference in New Issue
Block a user