Merge branch 'test' into dev
This commit is contained in:
+101
-21
@@ -331,6 +331,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
|
||||
tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM',
|
||||
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
|
||||
customer_nickname VARCHAR(32) NOT NULL COMMENT '客户昵称:创建时默认取公司名称中文拼音大写首字母,可修改',
|
||||
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用',
|
||||
legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
|
||||
@@ -578,6 +579,9 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间',
|
||||
estimated_volume_cbm DECIMAL(18,4) NULL COMMENT '平台运营预估体积(立方米),独立于客户下单及实测体积',
|
||||
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
|
||||
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注',
|
||||
customer_service_remark VARCHAR(1000) NULL COMMENT '客服备注',
|
||||
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
|
||||
requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间',
|
||||
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓逻辑ID',
|
||||
@@ -617,6 +621,33 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
|
||||
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
|
||||
|
||||
SET @order_internal_remark_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_order' AND table_name='ord_order' AND column_name='internal_remark'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE baiyue_order.ord_order ADD COLUMN internal_remark VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER customer_remark'
|
||||
);
|
||||
PREPARE order_internal_remark_stmt FROM @order_internal_remark_sql;
|
||||
EXECUTE order_internal_remark_stmt;
|
||||
DEALLOCATE PREPARE order_internal_remark_stmt;
|
||||
|
||||
SET @order_warehouse_remark_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_order' AND table_name='ord_order' AND column_name='warehouse_remark'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE baiyue_order.ord_order ADD COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT ''仓库备注'' AFTER internal_remark'
|
||||
);
|
||||
PREPARE order_warehouse_remark_stmt FROM @order_warehouse_remark_sql;
|
||||
EXECUTE order_warehouse_remark_stmt;
|
||||
DEALLOCATE PREPARE order_warehouse_remark_stmt;
|
||||
|
||||
SET @order_customer_service_remark_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_order' AND table_name='ord_order' AND column_name='customer_service_remark'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE baiyue_order.ord_order ADD COLUMN customer_service_remark VARCHAR(1000) NULL COMMENT ''客服备注'' AFTER warehouse_remark'
|
||||
);
|
||||
PREPARE order_customer_service_remark_stmt FROM @order_customer_service_remark_sql;
|
||||
EXECUTE order_customer_service_remark_stmt;
|
||||
DEALLOCATE PREPARE order_customer_service_remark_stmt;
|
||||
|
||||
SET @order_fulfillment_type_sql = IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
@@ -1245,15 +1276,24 @@ SET @ddl = IF(
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @customer_nickname_ddl = (SELECT IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='iam_tenant' AND column_name='customer_nickname'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE iam_tenant ADD COLUMN customer_nickname VARCHAR(32) NULL COMMENT ''客户昵称:创建时默认取公司名称中文拼音大写首字母,可修改'' AFTER tenant_name'));
|
||||
PREPARE customer_nickname_stmt FROM @customer_nickname_ddl;
|
||||
EXECUTE customer_nickname_stmt;
|
||||
DEALLOCATE PREPARE customer_nickname_stmt;
|
||||
|
||||
INSERT INTO iam_tenant (
|
||||
id, tenant_code, tenant_name, tenant_type, status, legal_name,
|
||||
id, tenant_code, tenant_name, customer_nickname, tenant_type, status, legal_name,
|
||||
unified_social_credit_code, contact_name, contact_phone, contact_email,
|
||||
approved_at, approved_by, created_by, deleted, version
|
||||
) VALUES
|
||||
(0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台',
|
||||
(0, 'PLATFORM', '虫虫出海平台', '平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台',
|
||||
NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type),
|
||||
tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), customer_nickname = VALUES(customer_nickname), tenant_type = VALUES(tenant_type),
|
||||
status = 'ACTIVE', legal_name = VALUES(legal_name),
|
||||
unified_social_credit_code = VALUES(unified_social_credit_code),
|
||||
contact_name = VALUES(contact_name), contact_phone = VALUES(contact_phone),
|
||||
@@ -5403,6 +5443,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
|
||||
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照',
|
||||
customer_service_remark VARCHAR(1000) NULL COMMENT '客服备注快照',
|
||||
frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间',
|
||||
frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID',
|
||||
removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID',
|
||||
@@ -5442,6 +5483,15 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
|
||||
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
||||
|
||||
SET @loading_item_customer_service_remark_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' AND column_name='customer_service_remark'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE baiyue_firstleg.fl_loading_plan_item ADD COLUMN customer_service_remark VARCHAR(1000) NULL COMMENT ''客服备注快照'' AFTER warehouse_remark'
|
||||
);
|
||||
PREPARE loading_item_customer_service_remark_stmt FROM @loading_item_customer_service_remark_sql;
|
||||
EXECUTE loading_item_customer_service_remark_stmt;
|
||||
DEALLOCATE PREPARE loading_item_customer_service_remark_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
|
||||
@@ -6144,7 +6194,9 @@ BEGIN
|
||||
IF current_plan_status = 'LOADING' AND EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
|
||||
IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN
|
||||
IF NOT (NEW.internal_remark <=> OLD.internal_remark)
|
||||
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
|
||||
OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN
|
||||
IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted
|
||||
OR NOT (NEW.customer_name <=> OLD.customer_name)
|
||||
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
|
||||
@@ -6172,14 +6224,13 @@ BEGIN
|
||||
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
|
||||
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
|
||||
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
|
||||
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
|
||||
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
|
||||
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
|
||||
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
|
||||
OR NOT (NEW.removed_by <=> OLD.removed_by)
|
||||
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
|
||||
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段';
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段';
|
||||
END IF;
|
||||
ELSEIF NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
@@ -6214,7 +6265,6 @@ BEGIN
|
||||
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
|
||||
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
|
||||
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
|
||||
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
|
||||
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
|
||||
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
|
||||
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
|
||||
@@ -14781,6 +14831,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan_item', 'updated_by
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan_item', 'version', '乐观锁版本号');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan_item', 'volume_cbm', '本次装柜体积m3');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan_item', 'warehouse_remark', '仓库备注快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan_item', 'customer_service_remark', '客服备注快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_measure_review_record', 'allow_rejected_config', '操作时Nacos异常放行开关值');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_measure_review_record', 'exception_release', '是否异常放行');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_measure_review_record', 'id', '测量审核记录ID');
|
||||
@@ -15256,6 +15307,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'service_package', '客户
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'status', '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_code', '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_name', '客户企业名称');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'customer_nickname', '客户昵称:创建时默认取公司名称中文拼音大写首字母,可修改');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_type', '主体类型:MERCHANT企业客户');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'unified_social_credit_code', '统一社会信用代码');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'updated_at', '更新时间');
|
||||
@@ -15348,6 +15400,9 @@ CALL queue_column_comment('baiyue_order', 'ord_order', 'created_by', '创建人I
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'customer_id', '客户逻辑ID');
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'customer_name', '下单时客户名称快照');
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'customer_remark', '客户可见备注');
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'internal_remark', '平台内部备注');
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'warehouse_remark', '仓库备注');
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'customer_service_remark', '客服备注');
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'customs_inspection', '是否需要商检:0否、1是');
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'deleted', '逻辑删除标记');
|
||||
CALL queue_column_comment('baiyue_order', 'ord_order', 'destination_country_code', '目的国家二字码');
|
||||
@@ -15848,6 +15903,9 @@ BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema='baiyue_iam' AND table_name='iam_tenant'
|
||||
AND column_name='dimension_initialization_status') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema='baiyue_iam' AND table_name='iam_tenant'
|
||||
AND column_name='customer_nickname') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_iam'
|
||||
AND table_name='iam_customer_dimension_initialization_task') <> 1
|
||||
@@ -17431,7 +17489,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),
|
||||
(5107003,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),
|
||||
@@ -17686,6 +17744,29 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
|
||||
-- 转仓审核权限与管理员授权必须实际落库,不能被其他权限的 ID 冲突静默吞掉。
|
||||
DROP PROCEDURE IF EXISTS assert_warehouse_transfer_approval_permission;
|
||||
DELIMITER $
|
||||
CREATE PROCEDURE assert_warehouse_transfer_approval_permission()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM iam_permission WHERE id=5107003 AND tenant_id=0
|
||||
AND permission_code='platform:overseas-warehouse:warehouse-transfer:approve'
|
||||
AND permission_type='BUTTON' AND status='ACTIVE' AND deleted=0) <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='转仓审核权限初始化失败:权限ID或编码冲突';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_role role WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND NOT EXISTS (SELECT 1 FROM iam_role_permission grant_row
|
||||
WHERE grant_row.tenant_id=0 AND grant_row.role_id=role.id AND grant_row.permission_id=5107003)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='转仓审核权限初始化失败:平台管理员授权缺失';
|
||||
END IF;
|
||||
END$
|
||||
DELIMITER ;
|
||||
CALL assert_warehouse_transfer_approval_permission();
|
||||
DROP PROCEDURE assert_warehouse_transfer_approval_permission;
|
||||
|
||||
-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound and delivery lists shown in the admin navigation.
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, NOW(3)
|
||||
@@ -17811,6 +17892,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 (
|
||||
@@ -17888,18 +17981,6 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
'merchant:warehouse:delivery:create')
|
||||
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;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase5_delivery_permissions()
|
||||
@@ -18028,7 +18109,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',
|
||||
@@ -1756,6 +1799,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID',
|
||||
source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照',
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -2264,6 +2310,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 +2500,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;
|
||||
@@ -2518,6 +2567,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
||||
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
@@ -2687,6 +2737,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',
|
||||
@@ -4204,7 +4258,7 @@ BEGIN
|
||||
AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'
|
||||
AND (JSON_LENGTH(roles_json) <> 1
|
||||
OR NOT JSON_CONTAINS(roles_json, JSON_QUOTE('PLATFORM_ADMIN'))
|
||||
OR JSON_LENGTH(authorities_json) <> 105
|
||||
OR JSON_LENGTH(authorities_json) <> 106
|
||||
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:last-mile-order:list'))
|
||||
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:inbound-order:list'))
|
||||
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:dispatch:list'))
|
||||
@@ -4539,6 +4593,7 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
source_return_no VARCHAR(2000) NULL COMMENT '来源退货单号',
|
||||
fba_carton_mark VARCHAR(255) NULL COMMENT 'FBA箱唛',
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
|
||||
relabel_application_id BIGINT NULL COMMENT '绑定换标申请单ID',
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@
|
||||
## 国内货柜履约装柜与 PDA 资源结算
|
||||
|
||||
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。
|
||||
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
|
||||
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
||||
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
|
||||
|
||||
@@ -19,6 +19,6 @@ public interface IamCustomerSnapshotClient {
|
||||
@RequestParam(value = "status", required = false) String status,
|
||||
@RequestParam(value = "customerId", required = false) Long customerId);
|
||||
|
||||
record Customer(long id, String tenantCode, String tenantName, String status,
|
||||
record Customer(long id, String tenantCode, String tenantName, String customerNickname, String status,
|
||||
String contactName, String contactPhone) {}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,13 @@ public interface OrderCargoClient {
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
|
||||
Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg,
|
||||
String cargoRemark, String customerRemark, String latestInternalNote) {
|
||||
String cargoRemark, String customerRemark, String latestInternalNote,
|
||||
String warehouseRemark, String customerServiceRemark) {
|
||||
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm) {
|
||||
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
|
||||
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null);
|
||||
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
@@ -58,7 +59,7 @@ public interface OrderCargoClient {
|
||||
String cargoRemark) {
|
||||
this(cargoId, orderId, itemNo, cargoName, originalPackageCount, originalGrossWeightKg,
|
||||
originalVolumeCbm, skuNo, cargoNameEn, unitsPerPackage, totalQuantity, netWeightKg,
|
||||
cargoRemark, null, null);
|
||||
cargoRemark, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,16 @@ public interface PrintPdfClient {
|
||||
record DropshipPalletA4PdfRequest(String templateCode, PageSpec pageSpec,
|
||||
String downloadFileName, List<DropshipPalletDocument> documents) {}
|
||||
|
||||
record DropshipPalletDocument(String palletNo, String inboundCodes, int cartonCount,
|
||||
int skuCount, BigDecimal weightKg, BigDecimal volumeCbm,
|
||||
String barcodeContent, List<DropshipPalletSkuRow> skuRows) {}
|
||||
record DropshipPalletDocument(String palletNo, String destinationWarehouse,
|
||||
int palletSequence, int totalPallets,
|
||||
String barcodeContent,
|
||||
List<DropshipPalletInboundRow> inboundRows,
|
||||
List<DropshipPalletSkuRow> skuRows) {}
|
||||
|
||||
record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName,
|
||||
int cartonCount, int skuQuantity) {}
|
||||
record DropshipPalletInboundRow(String inboundCode, String customerNickname,
|
||||
int cartonCount, int itemQuantity) {}
|
||||
|
||||
record DropshipPalletSkuRow(String skuCode, int cartonCount,
|
||||
int itemQuantity, String unitsPerCarton) {}
|
||||
|
||||
}
|
||||
|
||||
+2
-2
@@ -142,7 +142,7 @@ public class PlatformDropshipPalletizationController {
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||
public ApiResponse<List<Pallet>> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long inboundId,
|
||||
@RequestParam @Positive long accountId) {
|
||||
@@ -151,7 +151,7 @@ public class PlatformDropshipPalletizationController {
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets/{palletId}/print")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||
public ResponseEntity<byte[]> printPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long inboundId,
|
||||
@PathVariable @Positive long palletId,
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ public class PlatformSmartLoadingController {
|
||||
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
|
||||
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
|
||||
@PatchMapping("/plans/{planId}/items/{itemId}/warehouse-remark") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));}
|
||||
@PatchMapping("/plans/{planId}/items/{itemId}/remarks") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<Plan> remarks(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody RemarksRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateRemarks(planId,itemId,r,u.userId(),u.username()));}
|
||||
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
|
||||
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
|
||||
@DeleteMapping("/plans/{planId}/unpicked-items") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
|
||||
|
||||
@@ -240,6 +240,7 @@ public final class LoadingListDtos {
|
||||
String internalRemark,
|
||||
String customerRemark,
|
||||
String warehouseRemark,
|
||||
String customerServiceRemark,
|
||||
int loadSequence,
|
||||
LocalDateTime frozenAt,
|
||||
Integer itemVersion
|
||||
|
||||
@@ -83,7 +83,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record RemoveUnpickedRequest(@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
|
||||
public record RemarksRequest(@Size(max=1000) String internalRemark,
|
||||
@Size(max=1000) String warehouseRemark, @Size(max=1000) String customerServiceRemark,
|
||||
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
public record PlanCoLoadGroup(Long groupId,String groupNo,Long lockedPlanId,Integer memberOrderCount,
|
||||
|
||||
@@ -9,7 +9,7 @@ public class LoadingPlanItemEntity {
|
||||
private String unitType; private Long palletId; private String palletNo; private Boolean indivisible;
|
||||
private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
|
||||
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
|
||||
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
|
||||
private String internalRemark; private String customerRemark; private String warehouseRemark; private String customerServiceRemark; private LocalDateTime frozenAt; private Long frozenBy;
|
||||
private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted;
|
||||
@TableField(exist=false) private Long activeInboundDetailId;
|
||||
@TableField(exist=false) private Integer pickedPackageCount;
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.loading.repository.LoadingRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.service.InboundHoldGuard;
|
||||
import com.baiyue.firstleg.service.FbaPricingService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -43,6 +44,7 @@ public class LoadingDeliveryService {
|
||||
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private InboundHoldGuard inboundHoldGuard;
|
||||
@Resource private FbaPricingService fbaPricingService;
|
||||
|
||||
@Transactional
|
||||
public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId,
|
||||
@@ -67,6 +69,7 @@ public class LoadingDeliveryService {
|
||||
}
|
||||
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
|
||||
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
|
||||
fbaPricingService.autoSendAfterLoadingCompleted(tenantId, inboundId);
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse(inbound.processStatus()));
|
||||
}
|
||||
@@ -77,6 +80,7 @@ public class LoadingDeliveryService {
|
||||
requireCabinetEvidence(tenantId, inboundId);
|
||||
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
|
||||
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
|
||||
fbaPricingService.autoSendAfterLoadingCompleted(tenantId, inboundId);
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse("LOADED"));
|
||||
}
|
||||
@@ -120,6 +124,7 @@ public class LoadingDeliveryService {
|
||||
CompletionTarget target,
|
||||
boolean acceptedPdaCompletion) {
|
||||
if ("LOADED".equals(target.loadedChildStatus())) {
|
||||
fbaPricingService.autoSendAfterLoadingCompleted(tenantId, inboundId);
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse("LOADED"));
|
||||
}
|
||||
@@ -135,6 +140,7 @@ public class LoadingDeliveryService {
|
||||
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
|
||||
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
|
||||
request.remark(), operatorId, operatorName);
|
||||
fbaPricingService.autoSendAfterLoadingCompleted(tenantId, inboundId);
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse("LOADED"));
|
||||
}
|
||||
|
||||
@@ -203,6 +203,66 @@ public interface DropshipPalletizationMapper {
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE c.pallet_id=#{palletId}""")
|
||||
PalletMetrics metrics(@Param("palletId") long palletId);
|
||||
|
||||
@Select("""
|
||||
SELECT c.tenant_id,c.inbound_id,o.inbound_code,
|
||||
CASE REPLACE(UPPER(COALESCE(o.destination_code,'')),'.','')
|
||||
WHEN 'NJDF' THEN 'NJDF'
|
||||
WHEN 'CADF' THEN 'CADF'
|
||||
ELSE REPLACE(COALESCE(NULLIF(o.destination_name,''),NULLIF(o.destination_code,''),'—'),'.','')
|
||||
END destination_warehouse,
|
||||
COUNT(DISTINCT c.id) carton_count,
|
||||
SUM(COALESCE(q.item_quantity,c.actual_sku_quantity,0)) item_quantity
|
||||
FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
LEFT JOIN (SELECT carton_id,SUM(actual_quantity) item_quantity
|
||||
FROM fl_dropship_pallet_carton_sku GROUP BY carton_id) q ON q.carton_id=c.id
|
||||
WHERE c.pallet_id=#{palletId}
|
||||
GROUP BY c.tenant_id,c.inbound_id,o.inbound_code,o.destination_name,o.destination_code
|
||||
UNION ALL
|
||||
SELECT p.tenant_id,p.inbound_id,o.inbound_code,
|
||||
CASE REPLACE(UPPER(COALESCE(o.destination_code,'')),'.','')
|
||||
WHEN 'NJDF' THEN 'NJDF'
|
||||
WHEN 'CADF' THEN 'CADF'
|
||||
ELSE REPLACE(COALESCE(NULLIF(o.destination_name,''),NULLIF(o.destination_code,''),'—'),'.','')
|
||||
END destination_warehouse,
|
||||
0 carton_count,0 item_quantity
|
||||
FROM fl_dropship_pallet p
|
||||
JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
|
||||
WHERE p.id=#{palletId}
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id)
|
||||
ORDER BY inbound_code
|
||||
""")
|
||||
List<PrintInboundRow> printInboundRows(@Param("palletId") long palletId);
|
||||
|
||||
@Select("""
|
||||
SELECT sku_code,COUNT(DISTINCT carton_id) carton_count,SUM(item_quantity) item_quantity,
|
||||
MIN(item_quantity) min_units_per_carton,MAX(item_quantity) max_units_per_carton
|
||||
FROM (
|
||||
SELECT c.id carton_id,s.sku_code,SUM(s.actual_quantity) item_quantity
|
||||
FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_dropship_pallet_carton_sku s ON s.carton_id=c.id
|
||||
WHERE c.pallet_id=#{palletId}
|
||||
GROUP BY c.id,s.sku_code
|
||||
UNION ALL
|
||||
SELECT c.id carton_id,c.sku_code,c.actual_sku_quantity item_quantity
|
||||
FROM fl_dropship_pallet_carton c
|
||||
WHERE c.pallet_id=#{palletId}
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton_sku s WHERE s.carton_id=c.id)
|
||||
) pallet_sku
|
||||
GROUP BY sku_code ORDER BY sku_code
|
||||
""")
|
||||
List<PrintSkuRow> printSkuRows(@Param("palletId") long palletId);
|
||||
|
||||
@Select("""
|
||||
SELECT (SELECT COUNT(*) FROM fl_dropship_pallet listed
|
||||
WHERE listed.task_id=target.task_id AND listed.id<=target.id) sequence_no,
|
||||
(SELECT COUNT(*) FROM fl_dropship_pallet listed
|
||||
WHERE listed.task_id=target.task_id) total
|
||||
FROM fl_dropship_pallet target
|
||||
WHERE target.id=#{palletId}
|
||||
""")
|
||||
PalletPosition palletPosition(@Param("palletId") long palletId);
|
||||
@Select("""
|
||||
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count,
|
||||
(SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc
|
||||
@@ -221,18 +281,20 @@ public interface DropshipPalletizationMapper {
|
||||
SELECT p.id pallet_id,p.pallet_no,p.pallet_status,c.tenant_id account_id,c.inbound_id,o.order_id,o.order_no,
|
||||
o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,
|
||||
o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name,
|
||||
s.inbound_detail_id,s.sku_code,MAX(s.cargo_name) cargo_name,COUNT(DISTINCT c.id) carton_count,
|
||||
SUM(s.actual_quantity) actual_sku_quantity,
|
||||
COALESCE(s.inbound_detail_id,c.inbound_detail_id) inbound_detail_id,
|
||||
COALESCE(s.sku_code,c.sku_code) sku_code,MAX(COALESCE(s.cargo_name,c.cargo_name)) cargo_name,
|
||||
COUNT(DISTINCT c.id) carton_count,SUM(COALESCE(s.actual_quantity,c.actual_sku_quantity,0)) actual_sku_quantity,
|
||||
COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg,
|
||||
COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg,
|
||||
COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm
|
||||
FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
JOIN fl_dropship_pallet_carton_sku s ON s.carton_id=c.id
|
||||
LEFT JOIN fl_dropship_pallet_carton_sku s ON s.carton_id=c.id
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id WHERE p.id=#{palletId}
|
||||
GROUP BY p.id,p.pallet_no,p.pallet_status,c.tenant_id,c.inbound_id,o.order_id,o.order_no,o.inbound_code,
|
||||
o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name,
|
||||
o.customer_name,s.inbound_detail_id,s.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
|
||||
ORDER BY s.inbound_detail_id
|
||||
o.customer_name,COALESCE(s.inbound_detail_id,c.inbound_detail_id),COALESCE(s.sku_code,c.sku_code),
|
||||
o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
|
||||
ORDER BY inbound_detail_id
|
||||
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
|
||||
|
||||
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
|
||||
@@ -242,5 +304,8 @@ public interface DropshipPalletizationMapper {
|
||||
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
|
||||
@Getter @Setter class CartonSkuRow { private Long inboundDetailId; private String skuCode,cargoName,discrepancyStatus,discrepancyRemark; private Integer expectedQuantity,actualQuantity,lineNo; }
|
||||
record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {}
|
||||
@Getter @Setter class PrintInboundRow { private Long tenantId,inboundId; private String inboundCode,destinationWarehouse; private Integer cartonCount,itemQuantity; }
|
||||
@Getter @Setter class PrintSkuRow { private String skuCode; private Integer cartonCount,itemQuantity,minUnitsPerCarton,maxUnitsPerCarton; }
|
||||
record PalletPosition(int sequenceNo,int total) {}
|
||||
@Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; }
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@ public interface LoadingCompletedEventMapper {
|
||||
JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id
|
||||
JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId}
|
||||
AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id
|
||||
AND item.inbound_detail_id=carton.inbound_detail_id AND item.deleted=0
|
||||
AND item.inbound_detail_id=carton.inbound_detail_id
|
||||
AND item.pallet_id=pallet.id AND item.deleted=0
|
||||
WHERE pallet.pallet_status='SEALED'
|
||||
ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id
|
||||
""")
|
||||
@@ -101,7 +102,8 @@ public interface LoadingCompletedEventMapper {
|
||||
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p
|
||||
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id
|
||||
AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED'
|
||||
AND i.inbound_id=c.inbound_id AND i.pallet_id=p.id AND i.deleted=0
|
||||
WHERE p.pallet_status='SEALED'
|
||||
""")
|
||||
int workPalletCount(@Param("planId") long planId);
|
||||
|
||||
@@ -109,7 +111,8 @@ public interface LoadingCompletedEventMapper {
|
||||
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p
|
||||
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id
|
||||
AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN'
|
||||
AND i.inbound_id=c.inbound_id AND i.pallet_id=p.id AND i.deleted=0
|
||||
WHERE p.pallet_status='OPEN'
|
||||
""")
|
||||
int openWorkPalletCount(@Param("planId") long planId);
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ public interface LoadingListQueryMapper {
|
||||
* i.package_count / NULLIF(inbound.total_package_count,0)),0) pallet_count,
|
||||
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
|
||||
product_quantity, units_per_carton, fba_tracking_no, shipment_no,
|
||||
shipping_mark, internal_remark, customer_remark, warehouse_remark,
|
||||
shipping_mark, internal_remark, customer_remark, warehouse_remark, customer_service_remark,
|
||||
load_sequence, frozen_at, i.version AS item_version
|
||||
FROM fl_loading_plan_item i
|
||||
JOIN fl_loading_plan p ON p.id=i.loading_plan_id
|
||||
@@ -457,6 +457,7 @@ public interface LoadingListQueryMapper {
|
||||
private String internalRemark;
|
||||
private String customerRemark;
|
||||
private String warehouseRemark;
|
||||
private String customerServiceRemark;
|
||||
private Integer loadSequence;
|
||||
private LocalDateTime frozenAt;
|
||||
private Integer itemVersion;
|
||||
|
||||
@@ -11,7 +11,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
|
||||
int removeEditing(@Param("planId") long planId,@Param("itemId") long itemId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
@Update("""
|
||||
UPDATE fl_loading_plan_item i
|
||||
SET i.warehouse_remark=#{remark}, i.updated_by=#{operatorId}, i.updated_by_name=#{operatorName},
|
||||
SET i.internal_remark=#{internalRemark}, i.warehouse_remark=#{warehouseRemark},
|
||||
i.customer_service_remark=#{customerServiceRemark}, i.updated_by=#{operatorId}, i.updated_by_name=#{operatorName},
|
||||
i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1
|
||||
WHERE i.id=#{itemId} AND i.loading_plan_id=#{planId} AND i.deleted=0 AND i.frozen_at IS NULL
|
||||
AND i.version=#{expectedItemVersion}
|
||||
@@ -21,9 +22,12 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
|
||||
AND p.plan_status IN ('EDITING','LOADING')
|
||||
)
|
||||
""")
|
||||
int updateWarehouseRemark(@Param("planId") long planId, @Param("itemId") long itemId,
|
||||
int updateRemarks(@Param("planId") long planId, @Param("itemId") long itemId,
|
||||
@Param("touchedPlanVersion") int touchedPlanVersion,
|
||||
@Param("expectedItemVersion") int expectedItemVersion,
|
||||
@Param("remark") String remark, @Param("operatorId") long operatorId,
|
||||
@Param("internalRemark") String internalRemark,
|
||||
@Param("warehouseRemark") String warehouseRemark,
|
||||
@Param("customerServiceRemark") String customerServiceRemark,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
}
|
||||
|
||||
@@ -143,9 +143,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
SELECT current_group.id groupId,
|
||||
CASE WHEN current_group.id IS NULL THEN eligible.accountId END accountId,
|
||||
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END inboundId,
|
||||
CASE WHEN current_group.id IS NULL THEN eligible.palletId END palletId,
|
||||
MIN(eligible.expectedArrivalAt) sortArrivalAt,MIN(eligible.inboundId) sortInboundId
|
||||
FROM (
|
||||
SELECT o.tenant_id accountId,o.id inboundId,o.inbound_code inboundCode,
|
||||
available_pallet.palletId palletId,
|
||||
o.expected_arrival_at expectedArrivalAt,o.created_at createdAt,
|
||||
CASE WHEN o.process_status='WAIT_SPLIT' THEN 'WAIT_SPLIT'
|
||||
WHEN o.process_status='LOADING' THEN 'LOADING' ELSE 'WAIT_MEASURE' END candidateStatus,
|
||||
@@ -165,6 +167,18 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id
|
||||
AND split_item.parent_inbound_id=o.id),0) availablePackageCount
|
||||
FROM fl_inbound_order o
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT carton.tenant_id accountId,carton.inbound_id inboundId,pallet.id palletId
|
||||
FROM fl_dropship_pallet pallet
|
||||
JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id
|
||||
WHERE pallet.pallet_status='SEALED'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_plan_item pallet_item
|
||||
WHERE pallet_item.pallet_id=pallet.id AND pallet_item.deleted=0
|
||||
)
|
||||
) available_pallet
|
||||
ON available_pallet.accountId=o.tenant_id AND available_pallet.inboundId=o.id
|
||||
AND o.fulfillment_type='ONE_CLICK_DROPSHIP'
|
||||
WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId})
|
||||
AND COALESCE(o.hold_flag,0)=0
|
||||
AND o.channel_code IN
|
||||
@@ -174,11 +188,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED',
|
||||
'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','WAIT_SPLIT','LOADING')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_plan_item active_item
|
||||
WHERE active_item.account_id=o.tenant_id AND active_item.inbound_id=o.id
|
||||
AND active_item.deleted=0
|
||||
)
|
||||
AND ((COALESCE(o.fulfillment_type,'STANDARD')<>'ONE_CLICK_DROPSHIP' AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_plan_item active_item
|
||||
WHERE active_item.account_id=o.tenant_id AND active_item.inbound_id=o.id
|
||||
AND active_item.deleted=0
|
||||
)) OR (o.fulfillment_type='ONE_CLICK_DROPSHIP' AND available_pallet.palletId IS NOT NULL))
|
||||
) eligible
|
||||
LEFT JOIN fl_coload_group_member current_member
|
||||
ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId
|
||||
@@ -188,9 +202,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
WHERE eligible.candidateStatus<>'WAIT_SPLIT' OR eligible.availablePackageCount>0
|
||||
GROUP BY current_group.id,
|
||||
CASE WHEN current_group.id IS NULL THEN eligible.accountId END,
|
||||
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END
|
||||
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END,
|
||||
CASE WHEN current_group.id IS NULL THEN eligible.palletId END
|
||||
HAVING (current_group.id IS NULL OR (
|
||||
COUNT(*)=(SELECT COUNT(*) FROM fl_coload_group_member group_member_count
|
||||
COUNT(DISTINCT eligible.accountId,eligible.inboundId)=(SELECT COUNT(*) FROM fl_coload_group_member group_member_count
|
||||
WHERE group_member_count.group_id=current_group.id AND group_member_count.active_flag=1)
|
||||
))
|
||||
<if test="query.orderStatuses != null and !query.orderStatuses.isEmpty()">
|
||||
@@ -444,8 +459,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
private String channelCode;
|
||||
private Long availableContainerCount;
|
||||
}
|
||||
record CandidateUnit(Long groupId,Long accountId,Long inboundId,
|
||||
LocalDateTime sortArrivalAt,Long sortInboundId) {}
|
||||
record CandidateUnit(Long groupId,Long accountId,Long inboundId,Long palletId,
|
||||
LocalDateTime sortArrivalAt,Long sortInboundId) {
|
||||
public CandidateUnit(Long groupId,Long accountId,Long inboundId,
|
||||
LocalDateTime sortArrivalAt,Long sortInboundId) {
|
||||
this(groupId,accountId,inboundId,null,sortArrivalAt,sortInboundId);
|
||||
}
|
||||
}
|
||||
record PlanColoadMemberRow(Long groupId,String groupNo,Long lockedPlanId,
|
||||
Long orderId,Long inboundId,Integer fullPackageCount) {}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ public class LoadingListRepository {
|
||||
decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()),
|
||||
decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()),
|
||||
integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
|
||||
row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(),
|
||||
row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(), row.getCustomerServiceRemark(),
|
||||
integer(row.getLoadSequence()), row.getFrozenAt(), row.getItemVersion())).toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -147,10 +147,11 @@ public class SmartLoadingRepository {
|
||||
public int remove(long planId, long itemId, int version, long operatorId, String operatorName) {
|
||||
return items.removeEditing(planId, itemId, version, operatorId, operatorName);
|
||||
}
|
||||
public int updateWarehouseRemark(long planId, long itemId, int touchedPlanVersion, int expectedItemVersion,
|
||||
String remark, long operatorId, String operatorName) {
|
||||
return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion,
|
||||
remark, operatorId, operatorName);
|
||||
public int updateRemarks(long planId, long itemId, int touchedPlanVersion, int expectedItemVersion,
|
||||
String internalRemark, String warehouseRemark, String customerServiceRemark,
|
||||
long operatorId, String operatorName) {
|
||||
return items.updateRemarks(planId, itemId, touchedPlanVersion, expectedItemVersion,
|
||||
internalRemark, warehouseRemark, customerServiceRemark, operatorId, operatorName);
|
||||
}
|
||||
public int startPicking(long planId, long operatorId, String operatorName) {
|
||||
return plans.startPicking(planId, operatorId, operatorName);
|
||||
|
||||
@@ -439,6 +439,25 @@ public class FbaPricingService {
|
||||
return response(tenantId, current.getId(), nextProcessStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 装柜完成是客户费用的最后自动推送节点。人工已发送时直接幂等返回;
|
||||
* 仍为初算时先自动确认,再复用正式发送状态机,确保费用明细和 Finance outbox 只生成一次。
|
||||
*/
|
||||
@Transactional
|
||||
public void autoSendAfterLoadingCompleted(long tenantId, long inboundId) {
|
||||
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
|
||||
if (inbound == null) return;
|
||||
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
|
||||
if (current == null || "SENT".equals(current.getFeeStatus())) return;
|
||||
String prefix = "loading-completed-" + inboundId;
|
||||
if ("CALCULATED".equals(current.getFeeStatus())) {
|
||||
confirm(tenantId, AUTOMATIC_CONFIRM_OPERATOR_ID, inboundId, prefix + "-confirm");
|
||||
} else if (!"CONFIRMED".equals(current.getFeeStatus())) {
|
||||
return;
|
||||
}
|
||||
send(tenantId, AUTOMATIC_CONFIRM_OPERATOR_ID, inboundId, prefix + "-send");
|
||||
}
|
||||
|
||||
public FeeSnapshotResponse latest(long tenantId, long inboundId) {
|
||||
InboundFeeEntity fee = repository.findLatestFee(tenantId, inboundId);
|
||||
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
|
||||
|
||||
@@ -35,4 +35,21 @@ public class IamCustomerSnapshotService {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户主数据服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
public String customerNickname(long customerId) {
|
||||
try {
|
||||
var response = client.page(internalToken, 1, 2, null, null, customerId);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| response.data().records() == null || response.data().records().size() != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "客户昵称不存在,请先完善客户资料");
|
||||
}
|
||||
var customer = response.data().records().get(0);
|
||||
if (customer.id() != customerId || !StringUtils.hasText(customer.customerNickname())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "客户昵称不存在,请先完善客户资料");
|
||||
}
|
||||
return customer.customerNickname().trim();
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户主数据服务暂不可用");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-13
@@ -5,9 +5,11 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.PrintPdfClient;
|
||||
import com.baiyue.firstleg.dto.InboundCartonLabels;
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -19,6 +21,9 @@ import org.springframework.util.StringUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
|
||||
|
||||
/** Allocates stable carton codes and forwards immutable label facts to Print. */
|
||||
@@ -34,6 +39,8 @@ public class InboundCartonLabelPrintService {
|
||||
private final InboundCartonLabelService labelService;
|
||||
private final PrintPdfClient printClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
@Resource private DropshipPalletizationMapper palletizationMapper;
|
||||
@Resource private IamCustomerSnapshotService customerSnapshots;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
@@ -115,7 +122,7 @@ public class InboundCartonLabelPrintService {
|
||||
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
|
||||
}
|
||||
|
||||
/** 一件代发 A4 托盘信息单:托盘号、Code 128 一维码和 SKU 归托明细。 */
|
||||
/** 一件代发 A4 托盘信息单:托盘、目的仓、入仓码和 SKU 汇总。 */
|
||||
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) {
|
||||
return generateDropshipPallets(List.of(pallet), inboundCode, requestId);
|
||||
}
|
||||
@@ -125,13 +132,14 @@ public class InboundCartonLabelPrintService {
|
||||
if (pallets == null || pallets.isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
|
||||
}
|
||||
Map<Long, String> customerNicknameCache = new HashMap<>();
|
||||
List<PrintPdfClient.DropshipPalletDocument> documents = pallets.stream()
|
||||
.map(pallet -> dropshipPalletDocument(pallet, inboundCode)).toList();
|
||||
.map(pallet -> dropshipPalletDocument(pallet, customerNicknameCache)).toList();
|
||||
String fileName = pallets.size() == 1
|
||||
? pallets.get(0).palletNo() + "-A4托盘信息单.pdf"
|
||||
: inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf";
|
||||
PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest(
|
||||
"DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM),
|
||||
"DROPSHIP_PALLET_A4_V2", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM),
|
||||
fileName, documents);
|
||||
try {
|
||||
return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request));
|
||||
@@ -140,20 +148,42 @@ public class InboundCartonLabelPrintService {
|
||||
}
|
||||
}
|
||||
|
||||
private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(Pallet pallet, String inboundCode) {
|
||||
if (pallet == null || !StringUtils.hasText(pallet.palletNo())) {
|
||||
private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(
|
||||
Pallet pallet, Map<Long, String> customerNicknameCache) {
|
||||
if (pallet == null || pallet.palletId() == null || !StringUtils.hasText(pallet.palletNo())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
|
||||
}
|
||||
String inboundCodes = pallet.items().stream().map(item -> item.inboundCode())
|
||||
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/"));
|
||||
if (!StringUtils.hasText(inboundCodes)) inboundCodes = inboundCode;
|
||||
List<PrintPdfClient.DropshipPalletSkuRow> skuRows = pallet.items().stream()
|
||||
.map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(),
|
||||
item.cargoName(), number(item.cartonCount()), number(item.skuQuantity())))
|
||||
List<DropshipPalletizationMapper.PrintInboundRow> inboundFacts =
|
||||
palletizationMapper.printInboundRows(pallet.palletId());
|
||||
if (inboundFacts == null || inboundFacts.isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘未关联入仓单,不能打印");
|
||||
}
|
||||
List<PrintPdfClient.DropshipPalletInboundRow> inboundRows = inboundFacts.stream()
|
||||
.map(row -> new PrintPdfClient.DropshipPalletInboundRow(
|
||||
row.getInboundCode(),
|
||||
customerNicknameCache.computeIfAbsent(row.getTenantId(), customerSnapshots::customerNickname),
|
||||
number(row.getCartonCount()), number(row.getItemQuantity())))
|
||||
.toList();
|
||||
String destinationWarehouse = inboundFacts.stream()
|
||||
.map(DropshipPalletizationMapper.PrintInboundRow::getDestinationWarehouse)
|
||||
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(" / "));
|
||||
if (!StringUtils.hasText(destinationWarehouse)) destinationWarehouse = "—";
|
||||
List<PrintPdfClient.DropshipPalletSkuRow> skuRows = palletizationMapper.printSkuRows(pallet.palletId())
|
||||
.stream().map(row -> new PrintPdfClient.DropshipPalletSkuRow(
|
||||
row.getSkuCode(), number(row.getCartonCount()), number(row.getItemQuantity()),
|
||||
unitsPerCarton(row))).toList();
|
||||
DropshipPalletizationMapper.PalletPosition position =
|
||||
palletizationMapper.palletPosition(pallet.palletId());
|
||||
return new PrintPdfClient.DropshipPalletDocument(
|
||||
pallet.palletNo(), inboundCodes, number(pallet.cartonCount()), number(pallet.skuCount()),
|
||||
pallet.weightKg(), pallet.volumeCbm(), pallet.palletNo(), skuRows);
|
||||
pallet.palletNo(), destinationWarehouse,
|
||||
position == null ? 1 : position.sequenceNo(), position == null ? 1 : position.total(),
|
||||
pallet.palletNo(), inboundRows, skuRows);
|
||||
}
|
||||
|
||||
private String unitsPerCarton(DropshipPalletizationMapper.PrintSkuRow row) {
|
||||
int min = number(row.getMinUnitsPerCarton());
|
||||
int max = number(row.getMaxUnitsPerCarton());
|
||||
return min == max ? Integer.toString(min) : min + "-" + max;
|
||||
}
|
||||
|
||||
private int number(Integer value) {
|
||||
|
||||
+37
-8
@@ -131,16 +131,17 @@ public class LoadingCompletedOutboxService {
|
||||
Map<String, PalletManifest> palletManifests) {
|
||||
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
|
||||
Row first = sorted.get(0);
|
||||
List<FirstLegLoadingPlanCompletedV2.Item> items = sorted.stream().map(row ->
|
||||
new FirstLegLoadingPlanCompletedV2.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(),
|
||||
row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(),
|
||||
row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(),
|
||||
row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(),
|
||||
String fulfillmentType = fulfillmentType(sorted);
|
||||
List<FirstLegLoadingPlanCompletedV2.Item> items = dropship(fulfillmentType)
|
||||
? eventItems(sorted, cartonMarks)
|
||||
: sorted.stream().map(row -> new FirstLegLoadingPlanCompletedV2.Item(
|
||||
string(row.getSourceInboundDetailId()), row.getSkuCode(), row.getProductNameZh(),
|
||||
row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(), row.getPalletCount(),
|
||||
row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), row.getVolumeCbm(),
|
||||
string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(),
|
||||
row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
|
||||
row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null,
|
||||
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of())))
|
||||
.toList();
|
||||
String fulfillmentType = fulfillmentType(sorted);
|
||||
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))).toList();
|
||||
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
|
||||
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
|
||||
? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream()
|
||||
@@ -161,6 +162,34 @@ public class LoadingCompletedOutboxService {
|
||||
destinationType(sorted), first.getLxCode());
|
||||
}
|
||||
|
||||
/** 一件代发装柜按托盘产生分配行,跨区订单明细仍须按来源入仓明细保持唯一。 */
|
||||
private List<FirstLegLoadingPlanCompletedV2.Item> eventItems(List<Row> rows,
|
||||
Map<Long, List<String>> cartonMarks) {
|
||||
Map<Long, List<Row>> byDetail = rows.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
Row::getSourceInboundDetailId, LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||
List<FirstLegLoadingPlanCompletedV2.Item> result = new ArrayList<>();
|
||||
for (List<Row> fragments : byDetail.values()) {
|
||||
Row first = fragments.get(0);
|
||||
if (fragments.stream().anyMatch(row -> !Objects.equals(first.getSkuCode(), row.getSkuCode())
|
||||
|| !Objects.equals(first.getDestinationWarehouseId(), row.getDestinationWarehouseId())
|
||||
|| !Objects.equals(first.getDestinationWarehouseCode(), row.getDestinationWarehouseCode()))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一入仓明细的装柜分片快照不一致");
|
||||
}
|
||||
List<String> marks = fragments.stream()
|
||||
.flatMap(row -> cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()).stream()).toList();
|
||||
result.add(new FirstLegLoadingPlanCompletedV2.Item(string(first.getSourceInboundDetailId()),
|
||||
first.getSkuCode(), first.getProductNameZh(), first.getProductNameEn(),
|
||||
sumInt(fragments, Row::getPackageCount), sumNullableInt(fragments, Row::getProductQuantity),
|
||||
sumNullableInt(fragments, Row::getPalletCount), sum(fragments, Row::getGrossWeightKg),
|
||||
sum(fragments, Row::getNetWeightKg), sum(fragments, Row::getDimensionalWeightKg),
|
||||
sum(fragments, Row::getVolumeCbm), string(first.getDestinationWarehouseId()),
|
||||
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), first.getFbaTrackingNo(),
|
||||
first.getShipmentNo(), first.getShippingMark(), first.getCustomerRemark(), first.getInternalRemark(),
|
||||
first.getWarehouseRemark(), null, marks));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 只按 Warehouse 已分配的物理 palletId 计数;无分配事实时保持 null。 */
|
||||
private static Integer actualPalletCount(List<PalletRow> palletRows) {
|
||||
Set<Long> palletIds = palletRows.stream().map(PalletRow::getPalletId)
|
||||
|
||||
@@ -175,15 +175,15 @@ public class SmartLoadingService {
|
||||
LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||
for (InboundCandidate candidate : all) {
|
||||
if (candidate.coLoadGroupId() == null) {
|
||||
units.put(unitKey(null, candidate.accountId(), candidate.inboundId()), candidate);
|
||||
units.put(unitKey(null, candidate.accountId(), candidate.inboundId(), candidate.palletId()), candidate);
|
||||
}
|
||||
}
|
||||
for (var entry : grouped.entrySet()) {
|
||||
InboundCandidate aggregate = globalGroupCandidate(entry.getValue());
|
||||
if (aggregate != null) units.put(unitKey(entry.getKey(), null, null), aggregate);
|
||||
if (aggregate != null) units.put(unitKey(entry.getKey(), null, null, null), aggregate);
|
||||
}
|
||||
List<InboundCandidate> records = unitPage.records().stream()
|
||||
.map(unit -> units.get(unitKey(unit.groupId(), unit.accountId(), unit.inboundId())))
|
||||
.map(unit -> units.get(unitKey(unit.groupId(), unit.accountId(), unit.inboundId(), unit.palletId())))
|
||||
.filter(Objects::nonNull).toList();
|
||||
return new PageResult<>(records, unitPage.page(), unitPage.size(), unitPage.total(), unitPage.pages());
|
||||
}
|
||||
@@ -273,15 +273,19 @@ public class SmartLoadingService {
|
||||
String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText)
|
||||
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、"));
|
||||
List<PalletCandidate> pallets=dropshipPallets.palletCandidates(first.inboundId());
|
||||
if(!pallets.isEmpty()) return pallets.stream().map(pallet -> new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
|
||||
if(!pallets.isEmpty()) return pallets.stream().map(pallet -> {
|
||||
String palletBlock = StringUtils.hasText(first.blockReason()) ? first.blockReason()
|
||||
: pallet.cartonCount() == null || pallet.cartonCount() <= 0 ? "托盘没有可加入的箱数" : null;
|
||||
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
|
||||
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
|
||||
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
||||
first.customerName(), goods, pallet.cartonCount(),
|
||||
pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.weightKg().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP),
|
||||
first.unitNetWeightKg(), pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.volumeCbm().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP),
|
||||
pallet.volumeCbm(),"SEALED_PALLET",pallet.palletId(),pallet.palletNo(),true,
|
||||
first.coLoadGroupId(), first.groupNo(), first.memberCount(),first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first),
|
||||
first.orderStatusSnapshot(),first.expectedArrivalAt(),List.of())).toList();
|
||||
first.coLoadGroupId(), first.groupNo(), first.memberCount(),first.groupTotalBoxes(), false, !StringUtils.hasText(palletBlock), palletBlock,
|
||||
first.orderStatusSnapshot(),first.expectedArrivalAt(),List.of());
|
||||
}).toList();
|
||||
return List.of(new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
|
||||
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
|
||||
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
||||
@@ -357,8 +361,8 @@ public class SmartLoadingService {
|
||||
};
|
||||
}
|
||||
|
||||
private static String unitKey(Long groupId, Long accountId, Long inboundId) {
|
||||
return groupId == null ? "I|" + accountId + '|' + inboundId : "G|" + groupId;
|
||||
private static String unitKey(Long groupId, Long accountId, Long inboundId, Long palletId) {
|
||||
return groupId == null ? "I|" + accountId + '|' + inboundId + '|' + palletId : "G|" + groupId;
|
||||
}
|
||||
|
||||
private static BigDecimal weightedAverage(List<InboundCandidate> members,
|
||||
@@ -903,6 +907,7 @@ public class SmartLoadingService {
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
Set<Long> visitedGroups = new HashSet<>();
|
||||
Set<Long> visitedPallets = new HashSet<>();
|
||||
for (InboundCandidate candidate : recommendationCandidates(booking)) {
|
||||
if (candidate.coLoadGroupId() != null) {
|
||||
if (!visitedGroups.add(candidate.coLoadGroupId()) || StringUtils.hasText(candidate.blockReason())) {
|
||||
@@ -923,6 +928,24 @@ public class SmartLoadingService {
|
||||
}
|
||||
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
|
||||
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
|
||||
if ("SEALED_PALLET".equals(candidate.unitType())) {
|
||||
if (candidate.palletId() == null || !visitedPallets.add(candidate.palletId())
|
||||
|| !Boolean.TRUE.equals(candidate.canAssign())) continue;
|
||||
List<com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletLine> currentPalletLines =
|
||||
dropshipPallets.sealedPalletLines(candidate.palletId());
|
||||
if (currentPalletLines.isEmpty()
|
||||
|| currentPalletLines.stream().anyMatch(line -> !"SEALED".equals(line.getPalletStatus()))) {
|
||||
continue;
|
||||
}
|
||||
int fitting = fittingBoxes(booking, repository.items(planId), candidate.availablePackageCount(),
|
||||
candidate.averageUnitVolumeCbm(), candidate.unitGrossWeightKg());
|
||||
if (fitting != candidate.availablePackageCount()) continue;
|
||||
plan = repository.plan(planId);
|
||||
assignPallet(planId, new AssignPalletRequest(candidate.palletId(), plan.getVersion(),
|
||||
"REC-PAL-" + sha256(operation + '|' + candidate.palletId()).substring(0, 48)),
|
||||
operatorId, operatorName);
|
||||
continue;
|
||||
}
|
||||
int count = fittingBoxes(booking, repository.items(planId), candidate.availablePackageCount(),
|
||||
candidate.averageUnitVolumeCbm(), candidate.unitGrossWeightKg());
|
||||
if (count > 0) {
|
||||
@@ -1020,42 +1043,55 @@ public class SmartLoadingService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_WAREHOUSE_REMARK", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "修改装柜仓库备注")
|
||||
public Plan updateWarehouseRemark(long planId, long itemId, WarehouseRemarkRequest request,
|
||||
long operatorId, String operatorName) {
|
||||
@BusinessAudit(event = "SMART_LOADING_REMARKS", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "修改装柜协作备注")
|
||||
public Plan updateRemarks(long planId, long itemId, RemarksRequest request,
|
||||
long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String remark = trim(request.warehouseRemark());
|
||||
if (!StringUtils.hasText(remark)) remark = null;
|
||||
String requestHash = sha256("WAREHOUSE_REMARK|" + planId + '|' + itemId + '|'
|
||||
+ Objects.toString(remark, "") + '|' + request.expectedPlanVersion() + '|'
|
||||
String internalRemark = nullableRemark(request.internalRemark());
|
||||
String warehouseRemark = nullableRemark(request.warehouseRemark());
|
||||
String customerServiceRemark = nullableRemark(request.customerServiceRemark());
|
||||
String requestHash = sha256("REMARKS|" + planId + '|' + itemId + '|'
|
||||
+ Objects.toString(internalRemark, "") + '|' + Objects.toString(warehouseRemark, "") + '|'
|
||||
+ Objects.toString(customerServiceRemark, "") + '|' + request.expectedPlanVersion() + '|'
|
||||
+ request.expectedItemVersion());
|
||||
Plan replay = replay(operation, planId, "WAREHOUSE_REMARK", itemId, requestHash);
|
||||
Plan replay = replay(operation, planId, "REMARKS", itemId, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
|
||||
LoadingPlanItemEntity item = repository.item(planId, itemId);
|
||||
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
|
||||
if (Objects.equals(item.getWarehouseRemark(), remark)) {
|
||||
if (Objects.equals(item.getInternalRemark(), internalRemark)
|
||||
&& Objects.equals(item.getWarehouseRemark(), warehouseRemark)
|
||||
&& Objects.equals(item.getCustomerServiceRemark(), customerServiceRemark)) {
|
||||
return view(plan, requireBooking(plan.getContainerBookingId()));
|
||||
}
|
||||
String before = json(values("warehouseRemark", item.getWarehouseRemark(), "version", item.getVersion()));
|
||||
String before = json(values("internalRemark", item.getInternalRemark(),
|
||||
"warehouseRemark", item.getWarehouseRemark(), "customerServiceRemark", item.getCustomerServiceRemark(),
|
||||
"version", item.getVersion()));
|
||||
try {
|
||||
touch(plan, operatorId, operatorName);
|
||||
if (repository.updateWarehouseRemark(planId, itemId, request.expectedPlanVersion() + 1,
|
||||
request.expectedItemVersion(), remark, operatorId, operatorName) != 1) {
|
||||
if (repository.updateRemarks(planId, itemId, request.expectedPlanVersion() + 1,
|
||||
request.expectedItemVersion(), internalRemark, warehouseRemark, customerServiceRemark,
|
||||
operatorId, operatorName) != 1) {
|
||||
throw conflict("装柜计划或明细已被其他人修改,请刷新后重试");
|
||||
}
|
||||
} catch (DataAccessException exception) {
|
||||
if (hasSqlState(exception, "45000")) {
|
||||
throw conflict("装柜计划或明细状态已变化,仓库备注未保存,请刷新后重试");
|
||||
throw conflict("装柜计划或明细状态已变化,备注未保存,请刷新后重试");
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
history(planId, itemId, "WAREHOUSE_REMARK", plan.getPlanStatus(), plan.getPlanStatus(), operation,
|
||||
before, json(values("warehouseRemark", remark, "requestHash", requestHash)), operatorId, operatorName);
|
||||
history(planId, itemId, "REMARKS", plan.getPlanStatus(), plan.getPlanStatus(), operation,
|
||||
before, json(values("internalRemark", internalRemark, "warehouseRemark", warehouseRemark,
|
||||
"customerServiceRemark", customerServiceRemark, "requestHash", requestHash)), operatorId, operatorName);
|
||||
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
|
||||
}
|
||||
|
||||
private static String nullableRemark(String value) {
|
||||
String remark = trim(value);
|
||||
return StringUtils.hasText(remark) ? remark : null;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM",
|
||||
resourceId = "#itemId", description = "删除智能装柜明细")
|
||||
@@ -1361,7 +1397,12 @@ public class SmartLoadingService {
|
||||
i.setNetWeightKg(unit(c.unitNetWeightKg()).multiply(BigDecimal.valueOf(count)));
|
||||
i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
|
||||
i.setPalletCount(estimatedPalletCount(c, count));
|
||||
applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
|
||||
applyCargo(i, cargo, count);
|
||||
i.setCustomerRemark(cargo.customerRemark());
|
||||
i.setInternalRemark(cargo.latestInternalNote());
|
||||
i.setWarehouseRemark(cargo.warehouseRemark());
|
||||
i.setCustomerServiceRemark(cargo.customerServiceRemark());
|
||||
i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
|
||||
}
|
||||
private int estimatedPalletCount(Candidate candidate, int packageCount) {
|
||||
InboundOrderEntity inbound = repository.inbound(candidate.accountId(), candidate.inboundId());
|
||||
@@ -1374,8 +1415,6 @@ public class SmartLoadingService {
|
||||
item.setSkuCode(cargo.skuNo());
|
||||
item.setProductNameEn(cargo.cargoNameEn());
|
||||
item.setUnitsPerCarton(cargo.unitsPerPackage());
|
||||
item.setCustomerRemark(cargo.customerRemark());
|
||||
item.setInternalRemark(cargo.latestInternalNote());
|
||||
int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0
|
||||
? boxes : cargo.originalPackageCount();
|
||||
if (cargo.unitsPerPackage() != null) {
|
||||
@@ -1396,9 +1435,7 @@ public class SmartLoadingService {
|
||||
&& Objects.equals(current.getSkuCode(), desired.getSkuCode())
|
||||
&& Objects.equals(current.getProductNameEn(), desired.getProductNameEn())
|
||||
&& Objects.equals(current.getUnitsPerCarton(), desired.getUnitsPerCarton())
|
||||
&& Objects.equals(current.getProductQuantity(), desired.getProductQuantity())
|
||||
&& Objects.equals(current.getCustomerRemark(), desired.getCustomerRemark())
|
||||
&& Objects.equals(current.getInternalRemark(), desired.getInternalRemark());
|
||||
&& Objects.equals(current.getProductQuantity(), desired.getProductQuantity());
|
||||
}
|
||||
|
||||
private static boolean decimalEquals(BigDecimal left, BigDecimal right) {
|
||||
@@ -1411,7 +1448,8 @@ public class SmartLoadingService {
|
||||
"palletCount", item.getPalletCount(), "skuCode", item.getSkuCode(),
|
||||
"productNameEn", item.getProductNameEn(), "unitsPerCarton", item.getUnitsPerCarton(),
|
||||
"productQuantity", item.getProductQuantity(), "customerRemark", item.getCustomerRemark(),
|
||||
"internalRemark", item.getInternalRemark(), "version", item.getVersion());
|
||||
"internalRemark", item.getInternalRemark(), "warehouseRemark", item.getWarehouseRemark(),
|
||||
"customerServiceRemark", item.getCustomerServiceRemark(), "version", item.getVersion());
|
||||
}
|
||||
|
||||
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
|
||||
|
||||
@@ -319,6 +319,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
|
||||
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照',
|
||||
customer_service_remark VARCHAR(1000) NULL COMMENT '客服备注快照',
|
||||
frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间',
|
||||
frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID',
|
||||
removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID',
|
||||
@@ -358,6 +359,15 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
|
||||
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
||||
|
||||
SET @loading_item_customer_service_remark_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' AND column_name='customer_service_remark'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE baiyue_firstleg.fl_loading_plan_item ADD COLUMN customer_service_remark VARCHAR(1000) NULL COMMENT ''客服备注快照'' AFTER warehouse_remark'
|
||||
);
|
||||
PREPARE loading_item_customer_service_remark_stmt FROM @loading_item_customer_service_remark_sql;
|
||||
EXECUTE loading_item_customer_service_remark_stmt;
|
||||
DEALLOCATE PREPARE loading_item_customer_service_remark_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
|
||||
@@ -1036,7 +1046,9 @@ BEGIN
|
||||
IF current_plan_status = 'LOADING' AND EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
|
||||
IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN
|
||||
IF NOT (NEW.internal_remark <=> OLD.internal_remark)
|
||||
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
|
||||
OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN
|
||||
IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted
|
||||
OR NOT (NEW.customer_name <=> OLD.customer_name)
|
||||
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
|
||||
@@ -1064,14 +1076,13 @@ BEGIN
|
||||
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
|
||||
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
|
||||
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
|
||||
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
|
||||
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
|
||||
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
|
||||
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
|
||||
OR NOT (NEW.removed_by <=> OLD.removed_by)
|
||||
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
|
||||
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段';
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段';
|
||||
END IF;
|
||||
ELSEIF NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
@@ -1106,7 +1117,6 @@ BEGIN
|
||||
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
|
||||
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
|
||||
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
|
||||
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
|
||||
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
|
||||
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
|
||||
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
|
||||
|
||||
@@ -68,7 +68,7 @@ paths:
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print:
|
||||
get:
|
||||
summary: PDA 获取预生成托盘码的 A4 托盘信息单
|
||||
description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、入仓码、箱数以及已归托 SKU、商品名称和实点数量,不展示作业状态。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。
|
||||
description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、目的仓、托盘序号、入仓码/客户昵称/箱数/件数汇总以及 SKU/箱数/件数/每箱件数汇总,不展示作业状态、重量或体积。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TaskId'}
|
||||
@@ -87,12 +87,12 @@ paths:
|
||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
|
||||
get:
|
||||
summary: Admin 查询入仓单权威托盘结果
|
||||
x-required-authority: firstleg:inbound:list
|
||||
x-required-authority: platform:firstleg:inbound:list
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
|
||||
get:
|
||||
summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单
|
||||
x-required-authority: firstleg:inbound:list
|
||||
summary: 获取包含托盘码、目的仓、入仓码和 SKU 汇总的 A4 托盘信息单
|
||||
x-required-authority: platform:firstleg:inbound:list
|
||||
responses: {'200': {description: PDF}}
|
||||
components:
|
||||
parameters:
|
||||
|
||||
@@ -774,20 +774,21 @@ paths:
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingRemoveUnpickedRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark:
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/remarks:
|
||||
patch:
|
||||
summary: 修改装柜明细仓库备注
|
||||
summary: 修改装柜明细协作备注
|
||||
description: >-
|
||||
需要 platform:firstleg:preload:edit 权限。EDITING/LOADING 可修改或清空 warehouseRemark;
|
||||
需要 platform:firstleg:preload:edit 权限。客户备注只读;EDITING/LOADING 可修改或清空
|
||||
internalRemark、warehouseRemark 和 customerServiceRemark;
|
||||
COMPLETED、计划/明细版本冲突返回 A0409 且事务零写入,写入历史且与 PDA final complete 使用同一并发门禁。
|
||||
成功响应中的 version 是递增后的 planVersion,items[].version 是递增后的 itemVersion;调用方同时使用
|
||||
canEdit、items[].canAdjust/canRemove 与自身权限决定操作可用性。数量调整或动态增量不会覆盖已有 warehouseRemark。
|
||||
canEdit、items[].canAdjust/canRemove 与自身权限决定操作可用性。数量调整或动态增量不会覆盖已有协作备注。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}}
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingRemarksRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/openapi/v1/inbound-measurements:
|
||||
@@ -2323,11 +2324,13 @@ components:
|
||||
properties:
|
||||
expectedPlanVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
SmartLoadingWarehouseRemarkRequest:
|
||||
SmartLoadingRemarksRequest:
|
||||
type: object
|
||||
required: [expectedPlanVersion, expectedItemVersion, operationId]
|
||||
properties:
|
||||
internalRemark: {type: string, nullable: true, maxLength: 1000, description: 内部备注;null/空字符串均表示清空}
|
||||
warehouseRemark: {type: string, nullable: true, maxLength: 1000, description: 仓库备注;null/空字符串均表示清空}
|
||||
customerServiceRemark: {type: string, nullable: true, maxLength: 1000, description: 客服备注;null/空字符串均表示清空}
|
||||
expectedPlanVersion: {type: integer, minimum: 0}
|
||||
expectedItemVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
@@ -2518,8 +2521,9 @@ components:
|
||||
inboundCode: {type: string, nullable: true}
|
||||
customerName: {type: string, nullable: true}
|
||||
customerRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单 customerRemark}
|
||||
internalRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单最新内部备注,不使用 cargoRemark}
|
||||
internalRemark: {type: string, nullable: true, description: 内部备注;预装阶段可修改}
|
||||
warehouseRemark: {type: string, nullable: true, description: 仓库人员逐条维护,可在 EDITING/LOADING 修改}
|
||||
customerServiceRemark: {type: string, nullable: true, description: 客服备注;预装阶段可修改}
|
||||
packageCount: {type: integer, minimum: 1}
|
||||
totalPackageCount: {type: integer, minimum: 1, description: 当前入仓明细可在计划内分配的总箱数,包含已用与未用箱数}
|
||||
remainingPackageCount: {type: integer, minimum: 0, description: 当前入仓明细尚未分配到本计划的箱数}
|
||||
|
||||
@@ -405,6 +405,7 @@ components:
|
||||
internalRemark: {type: string, nullable: true, description: 内部备注}
|
||||
customerRemark: {type: string, nullable: true, description: 客户备注}
|
||||
warehouseRemark: {type: string, nullable: true, description: 仓库备注}
|
||||
customerServiceRemark: {type: string, nullable: true, description: 客服备注}
|
||||
loadSequence: {type: integer, format: int32, minimum: 0, description: 装柜顺序}
|
||||
frozenAt: {type: string, format: date-time, nullable: true, description: 明细冻结时间}
|
||||
itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion}
|
||||
|
||||
+4
-4
@@ -54,7 +54,7 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
authority("removeGroup","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,RemoveGroupRequest.class);
|
||||
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
|
||||
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
|
||||
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
|
||||
authority("remarks","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,RemarksRequest.class);
|
||||
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
|
||||
authority("removeUnpicked","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,RemoveUnpickedRequest.class);
|
||||
}
|
||||
@@ -77,13 +77,13 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
.contains("SmartLoadingAdjustRequest:")
|
||||
.contains("SmartLoadingDeleteRequest:")
|
||||
.contains("SmartLoadingRemoveUnpickedRequest:")
|
||||
.contains("SmartLoadingWarehouseRemarkRequest:")
|
||||
.contains("/items/{itemId}/warehouse-remark:")
|
||||
.contains("SmartLoadingRemarksRequest:")
|
||||
.contains("/items/{itemId}/remarks:")
|
||||
.contains("需要 platform:firstleg:preload:edit 权限")
|
||||
.contains("warehouseRemark: {type: string, nullable: true, maxLength: 1000")
|
||||
.contains("成功响应中的 version 是递增后的 planVersion")
|
||||
.contains("items[].version 是递增后的 itemVersion")
|
||||
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
|
||||
.contains("数量调整或动态增量不会覆盖已有协作备注")
|
||||
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%")
|
||||
.contains("人工分配不限制订单国内仓及渠道与柜次一致")
|
||||
.contains("智能推荐允许跨国内仓,但仍只选择柜次渠道")
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.service.InboundHoldGuard;
|
||||
import com.baiyue.firstleg.service.FbaPricingService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -30,6 +31,7 @@ class LoadingDeliveryServiceTest {
|
||||
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
|
||||
@MockBean ObjectMapper objectMapper;
|
||||
@MockBean InboundHoldGuard inboundHoldGuard;
|
||||
@MockBean FbaPricingService fbaPricingService;
|
||||
@Resource LoadingDeliveryService service;
|
||||
|
||||
@Test
|
||||
@@ -48,6 +50,7 @@ class LoadingDeliveryServiceTest {
|
||||
new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done"));
|
||||
|
||||
verify(cabinetEvidence).requireComplete(99L);
|
||||
verify(fbaPricingService).autoSendAfterLoadingCompleted(10L, 20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ class OceanBookingServiceTest {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
lenient().when(customers.active(eq(100L), any())).thenReturn(
|
||||
new com.baiyue.firstleg.client.IamCustomerSnapshotClient.Customer(100L, "C100", "客户", "ACTIVE", null, null));
|
||||
new com.baiyue.firstleg.client.IamCustomerSnapshotClient.Customer(100L, "C100", "客户", null, "ACTIVE", null, null));
|
||||
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
|
||||
var field = OceanBookingService.class.getDeclaredField("objectMapper");
|
||||
field.setAccessible(true);
|
||||
|
||||
+15
@@ -12,6 +12,21 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DropshipPalletizationSqlContractTest {
|
||||
@Test
|
||||
void inboundPalletReadAndPrintUseTheRegisteredPlatformInboundPermission() throws Exception {
|
||||
var controller = com.baiyue.firstleg.controller.PlatformDropshipPalletizationController.class;
|
||||
for (var method : controller.getDeclaredMethods()) {
|
||||
if (method.getName().equals("inboundPallets") || method.getName().equals("printPallet")) {
|
||||
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
|
||||
}
|
||||
}
|
||||
String api = new ClassPathResource("openapi/dropship-palletization-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(api).contains("x-required-authority: platform:firstleg:inbound:list")
|
||||
.doesNotContain("x-required-authority: firstleg:inbound:list");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingPdaTasksAreScopedByTheSelectedWarehouse() throws Exception {
|
||||
String sql = DropshipPalletizationMapper.class
|
||||
|
||||
+5
@@ -91,6 +91,11 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
.contains("status.name() == 'WAIT_SPLIT'")
|
||||
.contains("status.name() == 'LOADING'")
|
||||
.contains("NOT EXISTS (", "active_item.inbound_id=o.id", "active_item.deleted=0")
|
||||
.contains("available_pallet.palletId palletId")
|
||||
.contains("pallet.pallet_status='SEALED'")
|
||||
.contains("pallet_item.pallet_id=pallet.id", "pallet_item.deleted=0")
|
||||
.contains("o.fulfillment_type='ONE_CLICK_DROPSHIP' AND available_pallet.palletId IS NOT NULL")
|
||||
.contains("CASE WHEN current_group.id IS NULL THEN eligible.palletId END palletId")
|
||||
.contains("current_group.id groupId")
|
||||
.contains("group_member_count.group_id=current_group.id")
|
||||
.doesNotContain("fl_container_booking")
|
||||
|
||||
+9
-7
@@ -122,8 +122,8 @@ class SmartLoadingSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
|
||||
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
|
||||
@DisplayName("可编辑备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
|
||||
void shouldUpdateEditableRemarksWithoutMultiTableTriggerConflict() throws Exception {
|
||||
String mapper = Files.readString(Path.of(
|
||||
"src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8);
|
||||
assertThat(mapper)
|
||||
@@ -133,8 +133,8 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("i.frozen_at IS NULL")
|
||||
.doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p")
|
||||
.doesNotContain("p.version=p.version+1");
|
||||
assertWarehouseRemarkTriggerContract(migration());
|
||||
assertWarehouseRemarkTriggerContract(unifiedMigration());
|
||||
assertEditableRemarksTriggerContract(migration());
|
||||
assertEditableRemarksTriggerContract(unifiedMigration());
|
||||
}
|
||||
|
||||
private static void assertContainerLifecycleSchema(String sql) {
|
||||
@@ -151,11 +151,13 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("column_name='merged_into_container_booking_id'");
|
||||
}
|
||||
|
||||
private static void assertWarehouseRemarkTriggerContract(String sql) {
|
||||
private static void assertEditableRemarksTriggerContract(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN")
|
||||
.contains("IF NOT (NEW.internal_remark <=> OLD.internal_remark)")
|
||||
.contains("OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)")
|
||||
.contains("OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN")
|
||||
.contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted")
|
||||
.contains("已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段")
|
||||
.contains("已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段")
|
||||
.contains("ELSEIF NOT EXISTS (")
|
||||
.contains("IF OLD.frozen_at IS NOT NULL THEN")
|
||||
.contains("装柜完成后的明细永久冻结");
|
||||
|
||||
+12
-2
@@ -32,7 +32,7 @@ class IamCustomerSnapshotServiceTest {
|
||||
@Test
|
||||
void resolvesExactlyOneActiveCustomerAndUsesServerName() {
|
||||
var customer = new IamCustomerSnapshotClient.Customer(
|
||||
7L, "C-7", "Canonical Customer", "ACTIVE", null, null);
|
||||
7L, "C-7", "Canonical Customer", "Short Name", "ACTIVE", null, null);
|
||||
when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1)));
|
||||
|
||||
@@ -50,10 +50,20 @@ class IamCustomerSnapshotServiceTest {
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不存在或未启用");
|
||||
|
||||
var customer = new IamCustomerSnapshotClient.Customer(
|
||||
7L, "C-7", "Canonical Customer", "ACTIVE", null, null);
|
||||
7L, "C-7", "Canonical Customer", "Short Name", "ACTIVE", null, null);
|
||||
when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1)));
|
||||
assertThatThrownBy(() -> service.active(7L, "Forged Customer"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesCustomerNicknameForPalletDocument() {
|
||||
var customer = new IamCustomerSnapshotClient.Customer(
|
||||
7L, "C-7", "Canonical Customer", " Short Name ", "ACTIVE", null, null);
|
||||
when(client.page("internal-token", 1, 2, null, null, 7L))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1)));
|
||||
|
||||
assertThat(service.customerNickname(7L)).isEqualTo("Short Name");
|
||||
}
|
||||
}
|
||||
|
||||
+43
-4
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.client.PrintPdfClient;
|
||||
import com.baiyue.firstleg.dto.InboundCartonLabels;
|
||||
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
|
||||
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem;
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -29,17 +30,22 @@ import static org.mockito.Mockito.when;
|
||||
class InboundCartonLabelPrintServiceTest {
|
||||
private final InboundCartonLabelService labels = mock(InboundCartonLabelService.class);
|
||||
private final PrintPdfClient print = mock(PrintPdfClient.class);
|
||||
private final DropshipPalletizationMapper palletization = mock(DropshipPalletizationMapper.class);
|
||||
private final IamCustomerSnapshotService customerSnapshots = mock(IamCustomerSnapshotService.class);
|
||||
private InboundCartonLabelPrintService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InboundCartonLabelPrintService(labels, print, new ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||
ReflectionTestUtils.setField(service, "palletizationMapper", palletization);
|
||||
ReflectionTestUtils.setField(service, "customerSnapshots", customerSnapshots);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropshipPalletLabelIsAvailableBeforeSeal() {
|
||||
Pallet open = dropshipPallet("OPEN");
|
||||
stubPalletFacts(90001L, 1, 2);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
|
||||
@@ -54,17 +60,25 @@ class InboundCartonLabelPrintServiceTest {
|
||||
ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class);
|
||||
verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.eq("request-open"), request.capture());
|
||||
assertThat(request.getValue().templateCode()).isEqualTo("DROPSHIP_PALLET_A4_V1");
|
||||
assertThat(request.getValue().templateCode()).isEqualTo("DROPSHIP_PALLET_A4_V2");
|
||||
assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("210");
|
||||
assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("297");
|
||||
assertThat(request.getValue().documents()).singleElement().satisfies(document -> {
|
||||
assertThat(document.palletNo()).isEqualTo("DP-260911-0001");
|
||||
assertThat(document.inboundCodes()).isEqualTo("PCHK2608040174");
|
||||
assertThat(document.destinationWarehouse()).isEqualTo("NJDF");
|
||||
assertThat(document.palletSequence()).isEqualTo(1);
|
||||
assertThat(document.totalPallets()).isEqualTo(2);
|
||||
assertThat(document.inboundRows()).singleElement().satisfies(row -> {
|
||||
assertThat(row.inboundCode()).isEqualTo("PCHK2608040174");
|
||||
assertThat(row.customerNickname()).isEqualTo("小虫科技");
|
||||
assertThat(row.cartonCount()).isEqualTo(1);
|
||||
assertThat(row.itemQuantity()).isEqualTo(19);
|
||||
});
|
||||
assertThat(document.skuRows()).singleElement().satisfies(row -> {
|
||||
assertThat(row.skuCode()).isEqualTo("SKU-1");
|
||||
assertThat(row.cargoName()).isEqualTo("音箱");
|
||||
assertThat(row.cartonCount()).isEqualTo(1);
|
||||
assertThat(row.skuQuantity()).isEqualTo(19);
|
||||
assertThat(row.itemQuantity()).isEqualTo(19);
|
||||
assertThat(row.unitsPerCarton()).isEqualTo("19");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -74,6 +88,8 @@ class InboundCartonLabelPrintServiceTest {
|
||||
Pallet first = dropshipPallet("OPEN");
|
||||
Pallet second = new Pallet(90002L, "DP-260911-0002", "OPEN", 0, 0, 0,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, 1, List.of(), List.of(), null);
|
||||
stubPalletFacts(90001L, 1, 2);
|
||||
stubPalletFacts(90002L, 2, 2);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
|
||||
@@ -225,4 +241,27 @@ class InboundCartonLabelPrintServiceTest {
|
||||
return new Pallet(90001L, "DP-260911-0001", status, 1, 1, 0,
|
||||
BigDecimal.ONE, BigDecimal.ONE, 1, List.of(item), List.of(), null);
|
||||
}
|
||||
|
||||
private void stubPalletFacts(long palletId, int sequence, int total) {
|
||||
DropshipPalletizationMapper.PrintInboundRow inbound =
|
||||
new DropshipPalletizationMapper.PrintInboundRow();
|
||||
inbound.setTenantId(10001L);
|
||||
inbound.setInboundId(70001L);
|
||||
inbound.setInboundCode("PCHK2608040174");
|
||||
inbound.setDestinationWarehouse("NJDF");
|
||||
inbound.setCartonCount(palletId == 90001L ? 1 : 0);
|
||||
inbound.setItemQuantity(palletId == 90001L ? 19 : 0);
|
||||
when(palletization.printInboundRows(palletId)).thenReturn(List.of(inbound));
|
||||
DropshipPalletizationMapper.PrintSkuRow sku = new DropshipPalletizationMapper.PrintSkuRow();
|
||||
sku.setSkuCode("SKU-1");
|
||||
sku.setCartonCount(1);
|
||||
sku.setItemQuantity(19);
|
||||
sku.setMinUnitsPerCarton(19);
|
||||
sku.setMaxUnitsPerCarton(19);
|
||||
when(palletization.printSkuRows(palletId))
|
||||
.thenReturn(palletId == 90001L ? List.of(sku) : List.of());
|
||||
when(palletization.palletPosition(palletId))
|
||||
.thenReturn(new DropshipPalletizationMapper.PalletPosition(sequence, total));
|
||||
when(customerSnapshots.customerNickname(10001L)).thenReturn("小虫科技");
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -248,6 +248,12 @@ class LoadingCompletedOutboxServiceTest {
|
||||
String actualPalletsSql = String.join(" ", LoadingCompletedEventMapper.class
|
||||
.getMethod("actualPalletRows", long.class).getAnnotation(Select.class).value())
|
||||
.replaceAll("\\s+", " ");
|
||||
String workPalletCountSql = String.join(" ", LoadingCompletedEventMapper.class
|
||||
.getMethod("workPalletCount", long.class).getAnnotation(Select.class).value())
|
||||
.replaceAll("\\s+", " ");
|
||||
String openWorkPalletCountSql = String.join(" ", LoadingCompletedEventMapper.class
|
||||
.getMethod("openWorkPalletCount", long.class).getAnnotation(Select.class).value())
|
||||
.replaceAll("\\s+", " ");
|
||||
|
||||
assertThat(rowsSql).doesNotContain("p.plan_status = 'COMPLETED'")
|
||||
.doesNotContain("o.process_status = 'LOADED'")
|
||||
@@ -276,7 +282,10 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.doesNotContain("label.inbound_id = i.inbound_id");
|
||||
assertThat(actualPalletsSql)
|
||||
.contains("pallet.pallet_no AS pallet_code")
|
||||
.contains("item.pallet_id=pallet.id")
|
||||
.doesNotContain("pallet.pallet_code");
|
||||
assertThat(workPalletCountSql).contains("i.pallet_id=p.id");
|
||||
assertThat(openWorkPalletCountSql).contains("i.pallet_id=p.id");
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@@ -367,6 +376,32 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.doesNotStartWith("sha256:");
|
||||
}
|
||||
|
||||
@Test void mergesOneDropshipSourceDetailSplitAcrossTwoPalletsBeforePublishing() throws Exception {
|
||||
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
|
||||
Row second = row(11, 100, 101); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
|
||||
second.setLoadingPlanItemId(102L);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
|
||||
when(snapshots.workPalletCount(90L)).thenReturn(2);
|
||||
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
|
||||
actualPallet(11,100,"DP-260914-0021",101,"SKU101","BOX-001",10),
|
||||
actualPallet(11,100,"DP-260914-0022",101,"SKU101","BOX-002",10)));
|
||||
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox).insert(captor.capture());
|
||||
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(event.payload().at("/orders/0/items")).hasSize(1);
|
||||
assertThat(event.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
|
||||
assertThat(event.payload().at("/orders/0/items/0/packageCount").asInt()).isEqualTo(2);
|
||||
assertThat(event.payload().at("/orders/0/items/0/productQuantity").asInt()).isEqualTo(20);
|
||||
assertThat(event.payload().at("/orders/0/pallets")).hasSize(2);
|
||||
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText())
|
||||
.isEqualTo("101");
|
||||
assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/sourceInboundDetailId").asText())
|
||||
.isEqualTo("101");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
|
||||
@@ -16,11 +16,12 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.RemarksRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
||||
@@ -224,6 +225,42 @@ class SmartLoadingServiceTest {
|
||||
verify(repository, never()).booking(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pagesEachAvailableDropshipPalletAsAnIndependentCandidate() {
|
||||
Candidate dropship = withStatus(withChannel(candidate(9L, 10L, 20L, 30L, 12),
|
||||
"EXX_TRUCK", "EXX海卡"), "LOADING", true);
|
||||
InboundCandidateQuery query = new InboundCandidateQuery(
|
||||
List.of("EXX"), null, null, "EXXHWK2609140003", null, null,
|
||||
null, null, List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20);
|
||||
List<CandidateUnit> units = List.of(
|
||||
new CandidateUnit(null, 9L, 10L, 801L, null, 10L),
|
||||
new CandidateUnit(null, 9L, 10L, 802L, null, 10L));
|
||||
when(repository.globalCandidateUnits(eq(query), anySet()))
|
||||
.thenReturn(new PageResult<>(units, 1, 20, 2, 1));
|
||||
when(repository.globalCandidatesByUnits(units)).thenReturn(List.of(dropship));
|
||||
when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of(
|
||||
new PalletCandidate(801L, "DP-260914-0004", "SEALED_PALLET", true,
|
||||
8, 1, new BigDecimal("79.2"), new BigDecimal("4.4")),
|
||||
new PalletCandidate(802L, "DP-260914-0005", "SEALED_PALLET", true,
|
||||
4, 1, new BigDecimal("39.6"), new BigDecimal("2.2"))));
|
||||
|
||||
var result = service.inboundCandidates(query);
|
||||
|
||||
assertThat(result.total()).isEqualTo(2);
|
||||
assertThat(result.records()).extracting(InboundCandidate::palletId,
|
||||
InboundCandidate::palletNo, InboundCandidate::availablePackageCount)
|
||||
.containsExactly(
|
||||
org.assertj.core.groups.Tuple.tuple(801L, "DP-260914-0004", 8),
|
||||
org.assertj.core.groups.Tuple.tuple(802L, "DP-260914-0005", 4));
|
||||
assertThat(result.records()).allSatisfy(candidate -> {
|
||||
assertThat(candidate.unitType()).isEqualTo("SEALED_PALLET");
|
||||
assertThat(candidate.indivisible()).isTrue();
|
||||
assertThat(candidate.locked()).isFalse();
|
||||
assertThat(candidate.canAssign()).isTrue();
|
||||
assertThat(candidate.blockReason()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void combinesAllUnfinishedStatusesBeforeReturningTheRequestedPage() {
|
||||
InboundCandidateQuery query = new InboundCandidateQuery(
|
||||
@@ -735,6 +772,79 @@ class SmartLoadingServiceTest {
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recommendAssignsMultipleDropshipCandidatesThroughWholePalletCommands() {
|
||||
Candidate dropship = candidate(9L, 10L, 20L, 30L, 20);
|
||||
AtomicInteger version = new AtomicInteger();
|
||||
List<LoadingPlanItemEntity> saved = new ArrayList<>();
|
||||
InboundOrderEntity inbound = inbound(9L, 10L);
|
||||
inbound.setFulfillmentType("ONE_CLICK_DROPSHIP");
|
||||
when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get()));
|
||||
when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get()));
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved));
|
||||
when(repository.candidates(null)).thenReturn(List.of(dropship));
|
||||
when(repository.inbound(9L, 10L)).thenReturn(inbound);
|
||||
when(repository.activeItemsByPallet(anyLong())).thenReturn(List.of());
|
||||
when(repository.activePalletItem(eq(1L), anyLong(), eq(20L))).thenReturn(null);
|
||||
when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of(
|
||||
new PalletCandidate(801L, "DP-260914-0021", "SEALED_PALLET", true,
|
||||
10, 1, new BigDecimal("20"), new BigDecimal("5")),
|
||||
new PalletCandidate(802L, "DP-260914-0022", "SEALED_PALLET", true,
|
||||
10, 1, new BigDecimal("20"), new BigDecimal("5"))));
|
||||
when(dropshipPallets.sealedPalletLines(801L)).thenReturn(List.of(
|
||||
palletLine(801L, "DP-260914-0021", 10)));
|
||||
when(dropshipPallets.sealedPalletLines(802L)).thenReturn(List.of(
|
||||
palletLine(802L, "DP-260914-0022", 10)));
|
||||
when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> {
|
||||
int expected = invocation.getArgument(1);
|
||||
if (expected != version.get()) return 0;
|
||||
version.incrementAndGet();
|
||||
return 1;
|
||||
});
|
||||
doAnswer(invocation -> {
|
||||
LoadingPlanItemEntity item = invocation.getArgument(0);
|
||||
item.setId(800L + saved.size());
|
||||
saved.add(item);
|
||||
return null;
|
||||
}).when(repository).insert(any(LoadingPlanItemEntity.class));
|
||||
|
||||
Plan result = service.recommend(1L,
|
||||
new RecommendRequest(0, "recommend-dropship-pallet"), 7L, "u");
|
||||
|
||||
assertThat(result.version()).isEqualTo(2);
|
||||
assertThat(saved).hasSize(2).allSatisfy(item -> {
|
||||
assertThat(item.getUnitType()).isEqualTo("SEALED_PALLET");
|
||||
assertThat(item.getPackageCount()).isEqualTo(10);
|
||||
assertThat(item.getIndivisible()).isTrue();
|
||||
});
|
||||
assertThat(saved).extracting(LoadingPlanItemEntity::getPalletId,
|
||||
LoadingPlanItemEntity::getPalletNo).containsExactly(
|
||||
org.assertj.core.groups.Tuple.tuple(801L, "DP-260914-0021"),
|
||||
org.assertj.core.groups.Tuple.tuple(802L, "DP-260914-0022"));
|
||||
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recommendSkipsStalePalletCandidateInsteadOfFailingTheWholeRecommendation() {
|
||||
Candidate dropship = candidate(9L, 10L, 20L, 30L, 10);
|
||||
when(repository.plan(1L)).thenReturn(plan("EDITING", 0));
|
||||
when(repository.planByBooking(99L)).thenReturn(plan("EDITING", 0));
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of());
|
||||
when(repository.candidates(null)).thenReturn(List.of(dropship));
|
||||
when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of(
|
||||
new PalletCandidate(801L, "DP-STALE", "SEALED_PALLET", true,
|
||||
10, 1, new BigDecimal("20"), new BigDecimal("5"))));
|
||||
when(dropshipPallets.sealedPalletLines(801L)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.recommend(1L,
|
||||
new RecommendRequest(0, "recommend-stale-pallet"), 7L, "u");
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recommendRechecksCurrentCapacitySkipsStaleGroupAndContinuesWithLaterCandidate() {
|
||||
Candidate ordinary = candidate(9L, 10L, 20L, 30L, 4);
|
||||
@@ -1157,7 +1267,7 @@ class SmartLoadingServiceTest {
|
||||
when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success(
|
||||
new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10,
|
||||
new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name",
|
||||
4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注")));
|
||||
4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注", "仓库备注", "客服备注")));
|
||||
doAnswer(invocation -> {
|
||||
Object value = invocation.getArgument(0);
|
||||
if (value instanceof LoadingPlanItemEntity item) item.setId(88L);
|
||||
@@ -1178,6 +1288,8 @@ class SmartLoadingServiceTest {
|
||||
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5");
|
||||
assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
|
||||
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
|
||||
assertThat(saved.getWarehouseRemark()).isEqualTo("仓库备注");
|
||||
assertThat(saved.getCustomerServiceRemark()).isEqualTo("客服备注");
|
||||
assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注");
|
||||
|
||||
ArgumentCaptor<LoadingPlanHistoryEntity> historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
|
||||
@@ -1341,20 +1453,20 @@ class SmartLoadingServiceTest {
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of(line));
|
||||
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateRemarks(1L, 8L, 3, 0, null, null, null, 7L, "u")).thenReturn(1);
|
||||
|
||||
Plan result = service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u");
|
||||
Plan result = service.updateRemarks(1L, 8L,
|
||||
new RemarksRequest(null, " ", null, 2, 0, "warehouse-clear"), 7L, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
verify(loadingPickService).lockMutable(1L);
|
||||
var order = inOrder(loadingPickService, repository);
|
||||
order.verify(loadingPickService).lockMutable(1L);
|
||||
order.verify(repository).touch(1L, 2, 7L, "u");
|
||||
order.verify(repository).updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u");
|
||||
order.verify(repository).updateRemarks(1L, 8L, 3, 0, null, null, null, 7L, "u");
|
||||
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK");
|
||||
assertThat(history.getValue().getActionType()).isEqualTo("REMARKS");
|
||||
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
|
||||
}
|
||||
|
||||
@@ -1367,14 +1479,14 @@ class SmartLoadingServiceTest {
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of(line));
|
||||
when(repository.touch(1L, 1, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u")).thenReturn(1);
|
||||
when(repository.updateRemarks(1L, 8L, 2, 0, null, "待核对外箱", null, 7L, "u")).thenReturn(1);
|
||||
|
||||
Plan result = service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("待核对外箱", 1, 0, "warehouse-editing"), 7L, "u");
|
||||
Plan result = service.updateRemarks(1L, 8L,
|
||||
new RemarksRequest(null, "待核对外箱", null, 1, 0, "warehouse-editing"), 7L, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("EDITING");
|
||||
assertThat(result.version()).isEqualTo(2);
|
||||
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u");
|
||||
verify(repository).updateRemarks(1L, 8L, 2, 0, null, "待核对外箱", null, 7L, "u");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1387,13 +1499,13 @@ class SmartLoadingServiceTest {
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of(line));
|
||||
|
||||
Plan result = service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest(" 待核对外箱 ", 2, 0, "warehouse-noop"), 7L, "u");
|
||||
Plan result = service.updateRemarks(1L, 8L,
|
||||
new RemarksRequest(null, " 待核对外箱 ", null, 2, 0, "warehouse-noop"), 7L, "u");
|
||||
|
||||
assertThat(result.version()).isEqualTo(2);
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
|
||||
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), anyLong(), anyString());
|
||||
verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), any(), any(), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
}
|
||||
|
||||
@@ -1404,10 +1516,10 @@ class SmartLoadingServiceTest {
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.item(1L, 8L)).thenReturn(line);
|
||||
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u")).thenReturn(0);
|
||||
when(repository.updateRemarks(1L, 8L, 3, 0, null, "新备注", null, 7L, "u")).thenReturn(0);
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
|
||||
BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L,
|
||||
new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(repository).touch(1L, 2, 7L, "u");
|
||||
@@ -1421,15 +1533,15 @@ class SmartLoadingServiceTest {
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.item(1L, 8L)).thenReturn(line);
|
||||
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u"))
|
||||
when(repository.updateRemarks(1L, 8L, 3, 0, null, "新备注", null, 7L, "u"))
|
||||
.thenThrow(new DataIntegrityViolationException("trigger",
|
||||
new SQLException("状态冲突", "45000")));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class);
|
||||
BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L,
|
||||
new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error.getMessage()).contains("仓库备注未保存").contains("刷新后重试");
|
||||
assertThat(error.getMessage()).contains("备注未保存").contains("刷新后重试");
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
}
|
||||
|
||||
@@ -1437,46 +1549,46 @@ class SmartLoadingServiceTest {
|
||||
void stalePlanVersionRejectsWarehouseRemarkBeforeItemWrite() {
|
||||
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class);
|
||||
BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L,
|
||||
new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
|
||||
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), anyLong(), anyString());
|
||||
verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), any(), any(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() {
|
||||
when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 4, 0, "warehouse-completed"), 7L, "u"), BizException.class);
|
||||
BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L,
|
||||
new RemarksRequest(null, "新备注", null, 4, 0, "warehouse-completed"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(loadingPickService, never()).lockMutable(anyLong());
|
||||
verify(repository, never()).item(anyLong(), anyLong());
|
||||
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), anyLong(), anyString());
|
||||
verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), any(), any(), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactWarehouseRemarkReplayIsReadOnly() {
|
||||
String requestHash = sha256("WAREHOUSE_REMARK|1|8|备注|2|0");
|
||||
String requestHash = sha256("REMARKS|1|8||备注||2|0");
|
||||
when(repository.operation("warehouse-replay"))
|
||||
.thenReturn(history(1L, 8L, "WAREHOUSE_REMARK", requestHash));
|
||||
.thenReturn(history(1L, 8L, "REMARKS", requestHash));
|
||||
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
|
||||
Plan result = service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("备注", 2, 0, "warehouse-replay"), 7L, "u");
|
||||
Plan result = service.updateRemarks(1L, 8L,
|
||||
new RemarksRequest(null, "备注", null, 2, 0, "warehouse-replay"), 7L, "u");
|
||||
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(loadingPickService, never()).lockMutable(anyLong());
|
||||
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), anyLong(), anyString());
|
||||
verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), any(), any(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1708,6 +1820,21 @@ class SmartLoadingServiceTest {
|
||||
value.orderStatusSnapshot(), value.expectedArrivalAt());
|
||||
}
|
||||
|
||||
private static DropshipPalletizationMapper.PalletLine palletLine(long palletId, String palletNo,
|
||||
int cartonCount) {
|
||||
DropshipPalletizationMapper.PalletLine value = new DropshipPalletizationMapper.PalletLine();
|
||||
value.setPalletId(palletId); value.setPalletNo(palletNo); value.setPalletStatus("SEALED");
|
||||
value.setAccountId(9L); value.setInboundId(10L); value.setInboundDetailId(20L); value.setOrderId(30L);
|
||||
value.setOrderNo("O30"); value.setInboundCode("I10"); value.setWarehouseId(100L);
|
||||
value.setChannelCode("MATSON_CLX_TRUCK"); value.setChannelName("美森正班海卡");
|
||||
value.setDestinationWarehouseId(200L); value.setDestinationWarehouseCode("FBA1");
|
||||
value.setDestinationWarehouseName("FBA仓"); value.setCustomerName("客户"); value.setCargoName("货物");
|
||||
value.setCartonCount(cartonCount); value.setActualSkuQuantity(cartonCount);
|
||||
value.setUnitGrossWeightKg(new BigDecimal("2")); value.setUnitNetWeightKg(new BigDecimal("1.5"));
|
||||
value.setUnitVolumeCbm(new BigDecimal("0.5"));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Candidate withWarehouse(Candidate value, long warehouseId) {
|
||||
return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(),
|
||||
value.orderNo(), value.inboundCode(), warehouseId, value.channelCode(), value.channelName(),
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
|
||||
<dependency><groupId>com.belerweb</groupId><artifactId>pinyin4j</artifactId><version>2.5.1</version></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
|
||||
@@ -11,7 +11,7 @@ public final class CustomerExportDtos {
|
||||
public record CustomerExportBatch(List<CustomerExportRow> records, Long nextCursor, boolean hasMore) { }
|
||||
|
||||
public record CustomerExportRow(
|
||||
String id, String tenantCode, String tenantName, String legalName,
|
||||
String id, String tenantCode, String tenantName, String customerNickname, String legalName,
|
||||
String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate,
|
||||
BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress,
|
||||
String contactName, String contactPhone, String contactEmail, String contactWechat,
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class TenantDtos {
|
||||
@Schema(name = "TenantRegisterRequest", description = "Enterprise tenant registration / 企业租户注册资料")
|
||||
public record RegisterRequest(
|
||||
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
|
||||
@Schema(description = "Customer nickname; defaults to uppercase pinyin initials of Chinese characters in tenantName / 客户昵称,不填时默认取公司名称各中文字符的拼音大写首字母", example = "SZWL") @Size(max = 32) String customerNickname,
|
||||
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
|
||||
@Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative,
|
||||
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
|
||||
@@ -57,6 +58,7 @@ public final class TenantDtos {
|
||||
@Schema(name = "PlatformTenantUpdateRequest", description = "Platform maintained merchant tenant profile")
|
||||
public record UpdateTenantRequest(
|
||||
@NotBlank String tenantName,
|
||||
@Schema(description = "Customer nickname / 客户昵称") @NotBlank @Size(max = 32) String customerNickname,
|
||||
@NotBlank String legalName,
|
||||
@Size(max = 64) String legalRepresentative,
|
||||
@NotBlank String contactName,
|
||||
@@ -134,7 +136,7 @@ public final class TenantDtos {
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
|
||||
public record TenantResponse(Long id, String tenantCode, String tenantName, String status,
|
||||
public record TenantResponse(Long id, String tenantCode, String tenantName, String customerNickname, String status,
|
||||
String dimensionInitializationStatus,
|
||||
String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
|
||||
String contactPhone, String contactEmail, String contactWechat,
|
||||
|
||||
@@ -20,6 +20,7 @@ public class IamTenant extends BaseEntity {
|
||||
@TableId private Long id;
|
||||
private String tenantCode;
|
||||
private String tenantName;
|
||||
private String customerNickname;
|
||||
private String tenantType;
|
||||
private String status;
|
||||
private String dimensionInitializationStatus;
|
||||
|
||||
@@ -37,6 +37,7 @@ public class CustomerListExportProviderService {
|
||||
column("id", "客户ID", ExportColumnType.STRING, 20),
|
||||
column("tenantCode", "客户编码", ExportColumnType.STRING, 18),
|
||||
column("tenantName", "公司名称", ExportColumnType.STRING, 28),
|
||||
column("customerNickname", "客户昵称", ExportColumnType.STRING, 14),
|
||||
column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28),
|
||||
column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24),
|
||||
column("enterpriseType", "企业类型", ExportColumnType.STRING, 16),
|
||||
@@ -177,6 +178,7 @@ public class CustomerListExportProviderService {
|
||||
row.put("id", String.valueOf(tenant.getId()));
|
||||
row.put("tenantCode", tenant.getTenantCode());
|
||||
row.put("tenantName", tenant.getTenantName());
|
||||
row.put("customerNickname", tenant.getCustomerNickname());
|
||||
row.put("legalName", tenant.getLegalName());
|
||||
row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode());
|
||||
row.put("enterpriseType", tenant.getEnterpriseType());
|
||||
|
||||
@@ -70,6 +70,7 @@ public class MerchantOnboardingService {
|
||||
tenant.setId(tenantId);
|
||||
tenant.setTenantCode(customerCodes.nextCode());
|
||||
tenant.setTenantName(request.companyName());
|
||||
tenant.setCustomerNickname(TenantService.resolveCustomerNickname(null, request.companyName()));
|
||||
tenant.setTenantType("MERCHANT");
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
tenant.setDimensionInitializationStatus("PENDING");
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import feign.FeignException;
|
||||
import net.sourceforge.pinyin4j.PinyinHelper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -170,6 +171,7 @@ public class TenantService {
|
||||
tenant.setId(IdWorker.getId());
|
||||
tenant.setTenantCode(customerCodes.nextCode());
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setCustomerNickname(resolveCustomerNickname(request.customerNickname(), request.tenantName()));
|
||||
tenant.setTenantType("MERCHANT");
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
tenant.setDimensionInitializationStatus("PENDING");
|
||||
@@ -239,7 +241,7 @@ public class TenantService {
|
||||
|
||||
private CustomerExportRow exportRow(IamTenant tenant) {
|
||||
return new CustomerExportRow(String.valueOf(tenant.getId()), tenant.getTenantCode(),
|
||||
tenant.getTenantName(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(),
|
||||
tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(),
|
||||
tenant.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(),
|
||||
tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(),
|
||||
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
|
||||
@@ -390,6 +392,7 @@ public class TenantService {
|
||||
ensureNotArchived(tenant);
|
||||
String contactPhone = normalizeContactPhone(request.contactPhone());
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setCustomerNickname(requireCustomerNickname(request.customerNickname()));
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
|
||||
tenant.setContactName(request.contactName());
|
||||
@@ -489,6 +492,33 @@ public class TenantService {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
static String resolveCustomerNickname(String nickname, String tenantName) {
|
||||
if (nickname != null && !nickname.isBlank()) return nickname.trim();
|
||||
String normalizedName = tenantName == null ? "" : tenantName.trim();
|
||||
if (normalizedName.isEmpty()) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "公司名称不能为空");
|
||||
}
|
||||
StringBuilder initials = new StringBuilder();
|
||||
normalizedName.codePoints()
|
||||
.filter(codePoint -> Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN)
|
||||
.filter(Character::isBmpCodePoint)
|
||||
.forEach(codePoint -> {
|
||||
String[] pinyin = PinyinHelper.toHanyuPinyinStringArray((char) codePoint);
|
||||
if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) {
|
||||
initials.append(Character.toUpperCase(pinyin[0].charAt(0)));
|
||||
}
|
||||
});
|
||||
if (!initials.isEmpty()) return initials.substring(0, Math.min(initials.length(), 32));
|
||||
return new String(Character.toChars(normalizedName.codePointAt(0))).toUpperCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private String requireCustomerNickname(String nickname) {
|
||||
if (nickname == null || nickname.isBlank()) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "客户昵称不能为空");
|
||||
}
|
||||
return nickname.trim();
|
||||
}
|
||||
|
||||
private void ensureAdministratorPhoneAvailable(String phone) {
|
||||
if (!administratorPhoneAvailable(phone)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用");
|
||||
@@ -522,7 +552,7 @@ public class TenantService {
|
||||
|
||||
private TenantResponse response(IamTenant tenant) {
|
||||
return new TenantResponse(
|
||||
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
|
||||
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getStatus(),
|
||||
tenant.getDimensionInitializationStatus(),
|
||||
tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
|
||||
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
|
||||
@@ -545,7 +575,7 @@ public class TenantService {
|
||||
|
||||
private TenantResponse summaryResponse(IamTenant tenant) {
|
||||
TenantResponse response = response(tenant);
|
||||
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
|
||||
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.customerNickname(), response.status(),
|
||||
response.dimensionInitializationStatus(),
|
||||
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(),
|
||||
@@ -556,7 +586,7 @@ public class TenantService {
|
||||
|
||||
private TenantResponse merchantVisibleResponse(IamTenant tenant) {
|
||||
TenantResponse response = response(tenant);
|
||||
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
|
||||
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.customerNickname(), response.status(),
|
||||
response.dimensionInitializationStatus(),
|
||||
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(),
|
||||
|
||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
|
||||
tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM',
|
||||
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
|
||||
customer_nickname VARCHAR(32) NOT NULL COMMENT '客户昵称:创建时默认取公司名称中文拼音大写首字母,可修改',
|
||||
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档',
|
||||
dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败',
|
||||
|
||||
@@ -21,7 +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),
|
||||
(5107003,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),
|
||||
@@ -278,6 +278,29 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
|
||||
-- 转仓审核权限与管理员授权必须实际落库,不能被其他权限的 ID 冲突静默吞掉。
|
||||
DROP PROCEDURE IF EXISTS assert_warehouse_transfer_approval_permission;
|
||||
DELIMITER $
|
||||
CREATE PROCEDURE assert_warehouse_transfer_approval_permission()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM iam_permission WHERE id=5107003 AND tenant_id=0
|
||||
AND permission_code='platform:overseas-warehouse:warehouse-transfer:approve'
|
||||
AND permission_type='BUTTON' AND status='ACTIVE' AND deleted=0) <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='转仓审核权限初始化失败:权限ID或编码冲突';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_role role WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND NOT EXISTS (SELECT 1 FROM iam_role_permission grant_row
|
||||
WHERE grant_row.tenant_id=0 AND grant_row.role_id=role.id AND grant_row.permission_id=5107003)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='转仓审核权限初始化失败:平台管理员授权缺失';
|
||||
END IF;
|
||||
END$
|
||||
DELIMITER ;
|
||||
CALL assert_warehouse_transfer_approval_permission();
|
||||
DROP PROCEDURE assert_warehouse_transfer_approval_permission;
|
||||
|
||||
-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound and delivery lists shown in the admin navigation.
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, NOW(3)
|
||||
|
||||
@@ -1306,11 +1306,12 @@ components:
|
||||
pages: {type: integer, minimum: 0}
|
||||
TenantResponse:
|
||||
type: object
|
||||
required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage]
|
||||
required: [id, tenantCode, tenantName, customerNickname, status, dimensionInitializationStatus, servicePackage]
|
||||
properties:
|
||||
id: {type: string, pattern: '^\d+$', example: '190000000000000001'}
|
||||
tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'}
|
||||
tenantName: {type: string}
|
||||
customerNickname: {type: string, maxLength: 32, description: 客户昵称}
|
||||
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
|
||||
dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭}
|
||||
legalName: {type: string}
|
||||
@@ -1459,6 +1460,7 @@ components:
|
||||
required: [tenantName, legalName, contactName, contactPhone, servicePackage]
|
||||
properties:
|
||||
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
customerNickname: {type: string, maxLength: 32, example: SZWL, description: 选填;不填时默认取公司名称各中文字符的拼音大写首字母,没有中文时取首个非空字符并转大写}
|
||||
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填}
|
||||
contactName: {type: string, example: 李四}
|
||||
@@ -1486,9 +1488,10 @@ components:
|
||||
contactEmail: {type: string, format: email}
|
||||
UpdateTenantRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, contactName, contactPhone]
|
||||
required: [tenantName, customerNickname, legalName, contactName, contactPhone]
|
||||
properties:
|
||||
tenantName: {type: string}
|
||||
customerNickname: {type: string, maxLength: 32, description: 客户昵称}
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填}
|
||||
contactName: {type: string}
|
||||
|
||||
@@ -60,6 +60,8 @@ class CustomerProfileMigrationContractTest {
|
||||
String instructions = Files.readString(readme, StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("customer_nickname VARCHAR(32)")
|
||||
.contains("ALTER TABLE iam_tenant ADD COLUMN customer_nickname VARCHAR(32)")
|
||||
.contains("ALTER TABLE iam_tenant MODIFY COLUMN status")
|
||||
.contains("DEFAULT 'PROFILE_REQUIRED'")
|
||||
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'")
|
||||
|
||||
+3
-1
@@ -44,7 +44,9 @@ class OverseasPhase4PermissionSqlContractTest {
|
||||
assertPdaPickerGrant(sql);
|
||||
assertThat(catalog).hasSize(59).noneMatch(code -> code.endsWith(":view"));
|
||||
assertThat(sql).contains(
|
||||
"(5107231,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'");
|
||||
"(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'");
|
||||
assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()",
|
||||
"grant_row.permission_id=5107003", "权限ID或编码冲突", "平台管理员授权缺失");
|
||||
assertThat(sql).doesNotContain(
|
||||
"'platform:overseas-warehouse:warehouse-transfer:list','仓储转运审核'");
|
||||
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
|
||||
|
||||
@@ -50,13 +50,14 @@ class CustomerListExportProviderServiceTest {
|
||||
assertEquals(1, response.schemaVersion());
|
||||
assertThat(response.schemaHash()).matches("sha256:[0-9a-f]{64}");
|
||||
assertThat(response.snapshotToken()).matches("v1\\.103\\.[A-Za-z0-9_-]+");
|
||||
assertEquals(20, response.columns().size());
|
||||
assertEquals(21, response.columns().size());
|
||||
assertEquals("tenantCode", response.columns().get(1).key());
|
||||
assertEquals(ExportColumnType.DECIMAL,
|
||||
response.columns().stream().filter(column -> "registeredCapital".equals(column.key()))
|
||||
.findFirst().orElseThrow().type());
|
||||
assertEquals(2, response.rows().size());
|
||||
assertEquals("101", response.rows().get(0).get("id"));
|
||||
assertEquals("柏", response.rows().get(0).get("customerNickname"));
|
||||
assertEquals(new BigDecimal("1000000.00"), response.rows().get(0).get("registeredCapital"));
|
||||
assertEquals("启用", response.rows().get(0).get("status"));
|
||||
assertEquals("102", response.nextCursor());
|
||||
@@ -142,6 +143,7 @@ class CustomerListExportProviderServiceTest {
|
||||
tenant.setId(id);
|
||||
tenant.setTenantCode(String.format("%05d", id));
|
||||
tenant.setTenantName("柏悦物流" + id);
|
||||
tenant.setCustomerNickname("柏");
|
||||
tenant.setLegalName("柏悦物流有限公司");
|
||||
tenant.setUnifiedSocialCreditCode("91440000123456789X");
|
||||
tenant.setEnterpriseType("有限责任公司");
|
||||
|
||||
@@ -160,7 +160,7 @@ class TenantServiceTest {
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
|
||||
var updated = service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com",
|
||||
new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13800138000", "alice@example.com",
|
||||
"专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2),
|
||||
new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户"));
|
||||
var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"));
|
||||
@@ -187,7 +187,7 @@ class TenantServiceTest {
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
|
||||
var updated = service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null,
|
||||
new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13900139000", null,
|
||||
"基础版", null, null, 30, null, null, null, "CNY", null, null));
|
||||
|
||||
assertEquals("13900139000", updated.contactPhone());
|
||||
@@ -282,7 +282,7 @@ class TenantServiceTest {
|
||||
when(repository.findTenant(10001L)).thenReturn(archived);
|
||||
|
||||
BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null,
|
||||
new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13800138000", null,
|
||||
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
|
||||
BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L,
|
||||
new UpdateProfileRequest("New name", "New legal", "Bob", "Alice",
|
||||
@@ -317,7 +317,7 @@ class TenantServiceTest {
|
||||
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
|
||||
|
||||
var created = service.register(new RegisterRequest(
|
||||
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
|
||||
"Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000",
|
||||
"alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY",
|
||||
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"),
|
||||
9001L);
|
||||
@@ -325,6 +325,7 @@ class TenantServiceTest {
|
||||
assertEquals("PROFILE_REQUIRED", created.tenant().status());
|
||||
assertEquals("00001", created.tenant().tenantCode());
|
||||
assertEquals("PENDING", created.tenant().dimensionInitializationStatus());
|
||||
assertEquals("E", created.tenant().customerNickname());
|
||||
assertEquals("Bob", created.tenant().legalRepresentative());
|
||||
assertEquals("基础版", created.tenant().servicePackage());
|
||||
assertEquals("merchant_81", created.administratorUsername());
|
||||
@@ -337,13 +338,21 @@ class TenantServiceTest {
|
||||
verify(repository, never()).insertReview(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void customerNicknameDefaultsToUppercasePinyinInitialsAndAcceptsManualValue() {
|
||||
assertEquals("SZWLYXGS", TenantService.resolveCustomerNickname(null, "ABC 深圳物流有限公司"));
|
||||
assertEquals("SZWL", TenantService.resolveCustomerNickname(null, "深圳物流"));
|
||||
assertEquals("A", TenantService.resolveCustomerNickname(" ", "ABC Logistics"));
|
||||
assertEquals("虫虫", TenantService.resolveCustomerNickname(" 虫虫 ", "深圳物流有限公司"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() {
|
||||
doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败"))
|
||||
.when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class));
|
||||
|
||||
BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest(
|
||||
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
|
||||
"Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000",
|
||||
"alice@example.com", "基础版", null, null, 30, null,
|
||||
null, null, "CNY", null, null), 9001L));
|
||||
|
||||
@@ -357,7 +366,7 @@ class TenantServiceTest {
|
||||
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
|
||||
|
||||
var created = service.register(new RegisterRequest(
|
||||
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
|
||||
"Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000",
|
||||
"alice@example.com", "基础版", null, null, 30, null,
|
||||
null, null, "CNY", null, null), 9001L);
|
||||
|
||||
@@ -524,6 +533,7 @@ class TenantServiceTest {
|
||||
tenant.setStatus(status);
|
||||
tenant.setDimensionInitializationStatus("READY");
|
||||
tenant.setTenantName("Example Merchant");
|
||||
tenant.setCustomerNickname("E");
|
||||
tenant.setLegalName("Example Merchant Ltd");
|
||||
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
|
||||
tenant.setContactName("Alice");
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||
|
||||
+4
@@ -75,6 +75,10 @@ public interface InboundPlanOrderMapper {
|
||||
String findSourceContainerBookingId(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findOrderType(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
|
||||
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
|
||||
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
|
||||
|
||||
+21
-4
@@ -130,10 +130,23 @@ public class InboundPlanService {
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||
requiredReceivingWarehouse(containerWarehouseId);
|
||||
validateOrders(snapshots);
|
||||
int minimumPalletCount = suggestedPalletCount(order);
|
||||
int sourcePalletCount = Objects.requireNonNullElse(
|
||||
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
|
||||
minimumPalletCount = Math.max(minimumPalletCount, sourcePalletCount);
|
||||
String orderType = trimToNull(orderMapper.findOrderType(customerId, request.orderId()));
|
||||
int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? dropshipOrders.palletSnapshots(customerId, request.orderId()).size()
|
||||
: Objects.requireNonNullElse(
|
||||
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
|
||||
if (sourcePalletCount <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"一件代发订单缺少不可变来源托盘快照,不能生成入库计划");
|
||||
}
|
||||
if (request.palletCount() != sourcePalletCount) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"一件代发人工计划托数必须与头程封托托盘数一致:" + sourcePalletCount);
|
||||
}
|
||||
}
|
||||
int minimumPalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? sourcePalletCount : Math.max(suggestedPalletCount(order), sourcePalletCount);
|
||||
if (request.palletCount() < minimumPalletCount) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
|
||||
@@ -272,6 +285,10 @@ public class InboundPlanService {
|
||||
int targetPalletCount = Objects.requireNonNullElse(row.plannedPalletCount(),
|
||||
Objects.requireNonNullElse(suggested, 0));
|
||||
int supplementPalletCount = Math.max(0, targetPalletCount - sourcePalletCount);
|
||||
if (planned && "ONE_CLICK_DROPSHIP".equals(row.orderType())
|
||||
&& targetPalletCount != sourcePalletCount) {
|
||||
blockers.add("DROPSHIP_PALLET_COUNT_MISMATCH");
|
||||
}
|
||||
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
|
||||
String destinationType = destinationType(row);
|
||||
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
|
||||
|
||||
+3
-3
@@ -71,8 +71,8 @@ public class ReturnInventoryService {
|
||||
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
|
||||
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
||||
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
|
||||
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setCartonMark(cartonMark);
|
||||
carton.setSourceReturnNo(order.getReturnNo());
|
||||
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo());
|
||||
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
|
||||
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
|
||||
cartons.insert(carton);
|
||||
for (ReceiptLine line : lines) {
|
||||
@@ -253,9 +253,9 @@ public class ReturnInventoryService {
|
||||
}
|
||||
Map<String, Long> remaining = new LinkedHashMap<>(requested);
|
||||
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
|
||||
Map<Long, Map<String, Long>> attachedByOrder = 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());
|
||||
|
||||
@@ -87,11 +87,11 @@ public class LastlegOrderService {
|
||||
Page<LastlegOrder> page = orderMapper.selectPage(
|
||||
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt));
|
||||
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
|
||||
Map<Long, String> dispatchStatuses = dispatchStatuses(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,
|
||||
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
|
||||
StringUtils.hasText(order.getSourceContainerBookingId())
|
||||
? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
|
||||
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
@@ -581,7 +581,7 @@ public class LastlegOrderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
|
||||
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());
|
||||
@@ -591,14 +591,13 @@ public class LastlegOrderService {
|
||||
}
|
||||
|
||||
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
|
||||
String containerNode, String dispatchStatus) {
|
||||
String containerNode, String dispatchStatus) {
|
||||
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
|
||||
return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(),
|
||||
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
|
||||
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
|
||||
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(),
|
||||
containerNode,
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
|
||||
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
|
||||
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
|
||||
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
|
||||
|
||||
+52
-8
@@ -234,6 +234,8 @@ public class LoadingCompletedConsumer {
|
||||
String destinationType = destinationType(payload, source);
|
||||
String customerId = owner.customerId();
|
||||
String customerName = owner.customerName();
|
||||
List<FirstLegLoadingPlanCompletedV1.Item> items = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? canonicalItems(source.items()) : source.items();
|
||||
List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? pallets(source, rawSource) : List.of();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) {
|
||||
@@ -259,7 +261,7 @@ public class LoadingCompletedConsumer {
|
||||
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
|
||||
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
|
||||
order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt()));
|
||||
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
||||
order.setVolumeWeightKg(items.stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
|
||||
order.setSourceLoadingPlanId(payload.loadingPlanId());
|
||||
@@ -293,7 +295,7 @@ public class LoadingCompletedConsumer {
|
||||
throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致");
|
||||
}
|
||||
}
|
||||
for(var item:source.items()) {
|
||||
for(var item:items) {
|
||||
LastlegOrderDetail detail=new LastlegOrderDetail();
|
||||
detail.setCustomerId(customerId);
|
||||
detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode());
|
||||
@@ -377,11 +379,8 @@ public class LoadingCompletedConsumer {
|
||||
|
||||
private Map<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
||||
Map<String, FirstLegLoadingPlanCompletedV1.Item> itemsByDetail = new java.util.LinkedHashMap<>();
|
||||
for (var item : source.items()) {
|
||||
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()
|
||||
|| itemsByDetail.putIfAbsent(item.sourceInboundDetailId(), item) != null) {
|
||||
throw new IllegalArgumentException("一件代发来源明细标识缺失或重复");
|
||||
}
|
||||
for (var item : canonicalItems(source.items())) {
|
||||
itemsByDetail.put(item.sourceInboundDetailId(), item);
|
||||
}
|
||||
Map<String, List<String>> marksByDetail = new java.util.LinkedHashMap<>();
|
||||
for (JsonNode pallet : rawSource.path("pallets")) {
|
||||
@@ -395,7 +394,7 @@ public class LoadingCompletedConsumer {
|
||||
sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText()));
|
||||
}
|
||||
}
|
||||
for (var item : source.items()) {
|
||||
for (var item : itemsByDetail.values()) {
|
||||
if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) {
|
||||
throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致");
|
||||
}
|
||||
@@ -403,6 +402,51 @@ public class LoadingCompletedConsumer {
|
||||
return marksByDetail;
|
||||
}
|
||||
|
||||
/** 接受同一来源明细因多托装柜形成的多个分片,并在尾程落库前合并为唯一明细。 */
|
||||
private List<FirstLegLoadingPlanCompletedV1.Item> canonicalItems(List<FirstLegLoadingPlanCompletedV1.Item> source) {
|
||||
Map<String, List<FirstLegLoadingPlanCompletedV1.Item>> byDetail = new java.util.LinkedHashMap<>();
|
||||
for (var item : source == null ? List.<FirstLegLoadingPlanCompletedV1.Item>of() : source) {
|
||||
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) {
|
||||
throw new IllegalArgumentException("一件代发来源明细标识缺失");
|
||||
}
|
||||
byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item);
|
||||
}
|
||||
List<FirstLegLoadingPlanCompletedV1.Item> result = new ArrayList<>();
|
||||
for (List<FirstLegLoadingPlanCompletedV1.Item> fragments : byDetail.values()) {
|
||||
var first = fragments.get(0);
|
||||
if (fragments.stream().anyMatch(item -> !Objects.equals(first.skuCode(), item.skuCode())
|
||||
|| !Objects.equals(first.destinationWarehouseId(), item.destinationWarehouseId())
|
||||
|| !Objects.equals(first.destinationWarehouseCode(), item.destinationWarehouseCode()))) {
|
||||
throw new IllegalArgumentException("同一来源明细的尾程事件分片快照不一致");
|
||||
}
|
||||
List<String> marks = fragments.stream().flatMap(item -> item.cartonMarks().stream()).toList();
|
||||
result.add(new FirstLegLoadingPlanCompletedV1.Item(first.sourceInboundDetailId(), first.skuCode(),
|
||||
first.productNameZh(), first.productNameEn(),
|
||||
fragments.stream().mapToInt(FirstLegLoadingPlanCompletedV1.Item::packageCount).sum(),
|
||||
sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::productQuantity),
|
||||
sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::palletCount),
|
||||
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::grossWeightKg),
|
||||
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::netWeightKg),
|
||||
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg),
|
||||
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::volumeCbm),
|
||||
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
||||
first.fbaTrackingNo(), first.shipmentNo(), first.shippingMark(), first.palletNo(), marks));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static Integer sumNullableInt(List<FirstLegLoadingPlanCompletedV1.Item> values,
|
||||
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, Integer> getter) {
|
||||
List<Integer> present = values.stream().map(getter).filter(Objects::nonNull).toList();
|
||||
return present.isEmpty() ? null : present.stream().mapToInt(Integer::intValue).sum();
|
||||
}
|
||||
|
||||
private static BigDecimal sumDecimal(List<FirstLegLoadingPlanCompletedV1.Item> values,
|
||||
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, BigDecimal> getter) {
|
||||
List<BigDecimal> present = values.stream().map(getter).filter(Objects::nonNull).toList();
|
||||
return present.isEmpty() ? null : present.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
private void createCartonLabels(String customerId, Long orderId, String destinationType,
|
||||
FirstLegLoadingPlanCompletedV1.Item item, List<String> sourceMarks) {
|
||||
if (!"OVERSEAS".equals(destinationType)) return;
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ public final class WarehouseTransferRequests {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */
|
||||
public record Review(@NotBlank @Size(max = 32) String decision,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
|
||||
+5
-1
@@ -358,6 +358,9 @@ public class WarehouseTransferService {
|
||||
case "REJECT", REJECTED -> REJECTED;
|
||||
default -> throw badRequest("Review decision must be APPROVE or REJECT");
|
||||
};
|
||||
if (REJECTED.equals(next) && !StringUtils.hasText(request.remark())) {
|
||||
throw badRequest("驳回原因不能为空");
|
||||
}
|
||||
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
|
||||
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||
if (!PENDING_REVIEW.equals(transfer.getStatus())) {
|
||||
@@ -384,7 +387,8 @@ public class WarehouseTransferService {
|
||||
order.setUpdatedAt(now); orders.updateById(order);
|
||||
if (!java.util.Objects.equals(before, orderStatus)) {
|
||||
insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo,
|
||||
"Warehouse transfer reviewed: " + next, now);
|
||||
"Warehouse transfer reviewed: " + next
|
||||
+ (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now);
|
||||
}
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
|
||||
@@ -1708,6 +1708,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID',
|
||||
source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照',
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -2232,6 +2235,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',
|
||||
@@ -2488,6 +2492,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
||||
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
|
||||
+8
-10
@@ -233,11 +233,9 @@ class DispatchPdaWorkflowServiceTest {
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick();
|
||||
pick.setPackages(6L);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setOutboundNo("OB-1");
|
||||
outbound.setStatus("WAIT_OUTBOUND");
|
||||
outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
@@ -245,17 +243,17 @@ class DispatchPdaWorkflowServiceTest {
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7",
|
||||
new DispatchPdaRequests.Depart("depart-success", null));
|
||||
when(palletLocks.selectList(any())).thenReturn(List.of());
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart(
|
||||
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null));
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertEquals("DEPARTED", order.getStatus());
|
||||
assertTrue(order.getDepartAt() != null);
|
||||
assertEquals("OUTBOUNDED", outbound.getStatus());
|
||||
when(palletLocks.selectList(any())).thenReturn(List.of());
|
||||
assertNotNull(order.getDepartAt());
|
||||
assertEquals(order.getDepartAt(), order.getUpdatedAt());
|
||||
verify(orders).updateById(order);
|
||||
assertEquals("DEPARTED", order.getStatus());
|
||||
assertEquals("OUTBOUNDED", outbound.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+19
@@ -120,6 +120,25 @@ class ContainerInboundPlanPreviewTest {
|
||||
assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocksExistingDropshipPlanWhosePalletCountDiffersFromFrozenSourcePallets() {
|
||||
when(orderMapper.containerPreviewOrders("BOOKING-DROPSHIP-MISMATCH")).thenReturn(List.of(
|
||||
row(1L, "LM-DROPSHIP", "ONE_CLICK_DROPSHIP", "OVERSEAS", "US-LAX-01", "洛杉矶仓", 2,
|
||||
"IP-DROPSHIP", 2, 1, "PENDING_ASSIGNMENT", 0)
|
||||
));
|
||||
when(dropshipOrders.palletSnapshots("100", 1L)).thenReturn(List.of(
|
||||
new DropshipPalletSnapshot("DP-260914-0001", 10, List.of())));
|
||||
|
||||
var result = service.previewContainer("BOOKING-DROPSHIP-MISMATCH");
|
||||
var order = result.groups().get(0).orders().get(0);
|
||||
|
||||
assertFalse(result.ready());
|
||||
assertEquals("BLOCKED", order.readiness());
|
||||
assertEquals(List.of("DROPSHIP_PALLET_COUNT_MISMATCH"), order.blockers());
|
||||
assertEquals(1, order.sourcePalletCount());
|
||||
assertEquals(2, order.targetPalletCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesHeadLegExistingSupplementAndFinalPalletCountsWithoutBlocking() {
|
||||
when(orderMapper.containerPreviewOrders("BOOKING-SUPPLEMENT")).thenReturn(List.of(
|
||||
|
||||
+37
@@ -183,6 +183,43 @@ class InboundPlanServiceTest {
|
||||
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickDropshipManualPlanMustKeepTheFrozenSourcePalletCount() {
|
||||
SourceOrderSnapshot source = order(2L, "RK-2", "3.3650", "WH-A");
|
||||
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
|
||||
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
|
||||
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
|
||||
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
|
||||
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
|
||||
2, "WOOD", false, false, false, null), "dropship-pallet-mismatch", "900"));
|
||||
|
||||
assertEquals(400, error.getStatusCode().value());
|
||||
assertEquals("一件代发人工计划托数必须与头程封托托盘数一致:1", error.getReason());
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickDropshipUsesFrozenSourcePalletCountInsteadOfVolumeSuggestion() {
|
||||
SourceOrderSnapshot source = order(2L, "RK-2", "3.3650", "WH-A");
|
||||
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
|
||||
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
|
||||
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
|
||||
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
|
||||
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(0), any()))
|
||||
.thenReturn(1);
|
||||
|
||||
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
|
||||
LocalDate.of(2026, 9, 15), 1, "WOOD", false, false, false, null),
|
||||
"dropship-exact-pallet-count", "900");
|
||||
|
||||
assertEquals(1, result.palletCount());
|
||||
verify(planMapper).insert(any(InboundPlan.class));
|
||||
verify(palletMapper).insert(any(InboundPlanPallet.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerConversionBindsRealWarehousePalletsBackToSignedManualPlanBeforeReceiving() {
|
||||
InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL");
|
||||
|
||||
+22
@@ -12,6 +12,28 @@ import java.util.Map;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ReturnCartonQueryTest {
|
||||
@Test
|
||||
void baseMapperReplayColumnExistsInEveryReturnCartonBaseline() throws Exception {
|
||||
var table = com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), "return-carton-contract"),
|
||||
com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton.class);
|
||||
assertThat(table.getAllSqlSelect()).contains("new_carton_mark");
|
||||
for (var path : java.util.List.of(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"),
|
||||
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) {
|
||||
String sql = java.nio.file.Files.readString(path);
|
||||
var matcher = java.util.regex.Pattern.compile(
|
||||
"CREATE TABLE IF NOT EXISTS ll_return_inventory_carton \\(([\\s\\S]*?)\\) ENGINE")
|
||||
.matcher(sql);
|
||||
int definitions = 0;
|
||||
while (matcher.find()) {
|
||||
definitions++;
|
||||
assertThat(matcher.group(1)).as(path.toString()).contains("new_carton_mark VARCHAR(255)");
|
||||
}
|
||||
assertThat(definitions).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception {
|
||||
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT");
|
||||
|
||||
+26
@@ -147,6 +147,32 @@ class LoadingCompletedConsumerTest {
|
||||
verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
|
||||
verify(orders,never()).insert(any(LastlegOrder.class));
|
||||
}
|
||||
@Test void consumesOneDropshipSourceDetailSplitAcrossTwoPallets() {
|
||||
EventEnvelope original=overseasDropshipEvent(false);
|
||||
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
|
||||
var order=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0);
|
||||
var secondItem=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("items").get(0).deepCopy();
|
||||
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("items")).add(secondItem);
|
||||
order.put("packageCount",10).put("productQuantity",100).put("palletCount",2);
|
||||
var secondPallet=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("pallets").get(0).deepCopy();
|
||||
secondPallet.put("palletNo","DP-260914-0022");
|
||||
var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)secondPallet.path("skuPackages").get(0);
|
||||
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
|
||||
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets")).add(secondPallet);
|
||||
|
||||
service.consume(rehash(original,node));
|
||||
|
||||
var detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class);
|
||||
verify(details).insert(detailCaptor.capture());
|
||||
assertEquals("d-1",detailCaptor.getValue().getSourceInboundDetailId());
|
||||
assertEquals(10,detailCaptor.getValue().getPackageCount());
|
||||
assertEquals(100,detailCaptor.getValue().getProductQuantity());
|
||||
verify(cartons,times(10)).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
|
||||
var received=ArgumentCaptor.forClass(DropshipRequests.Receive.class);
|
||||
verify(dropshipOrders).receive(received.capture());
|
||||
assertEquals(List.of("DP-260909-0143","DP-260914-0022"),
|
||||
received.getValue().pallets().stream().map(DropshipRequests.Pallet::palletNo).toList());
|
||||
}
|
||||
@Test void mapsSameSkuDifferentSourceDetailsWithoutGuessing() {
|
||||
EventEnvelope original=overseasDropshipEvent(false);
|
||||
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
|
||||
|
||||
+36
-1
@@ -222,7 +222,7 @@ class WarehouseTransferServiceTest {
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
var result = service.review("TENANT-A", "WTR-1", "USER-1",
|
||||
new WarehouseTransferRequests.Review("APPROVE", "approved"));
|
||||
new WarehouseTransferRequests.Review("APPROVE", null));
|
||||
|
||||
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
|
||||
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
|
||||
@@ -233,6 +233,41 @@ class WarehouseTransferServiceTest {
|
||||
verifyNoInteractions(inventory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() {
|
||||
for (String decision : List.of("REJECT", "REJECTED")) {
|
||||
for (String reason : new String[] { null, "", " \t\n" }) {
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.review(
|
||||
"TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review(decision, reason)));
|
||||
assertEquals(400, error.getStatusCode().value());
|
||||
assertEquals("驳回原因不能为空", error.getReason());
|
||||
}
|
||||
}
|
||||
verifyNoInteractions(transfers, orders, orderHistories, inventory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionWithReasonCancelsLinkedOrderAndPreservesReasonInHistory() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
LastlegOrder tailOrder = historicalOrder();
|
||||
tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(31L)).thenReturn(tailOrder);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
var result = service.review("TENANT-A", "WTR-1", "USER-1",
|
||||
new WarehouseTransferRequests.Review("REJECT", " 地址不完整 "));
|
||||
assertEquals("REJECTED", result.status());
|
||||
assertEquals("CANCELLED", tailOrder.getCurrentStatus());
|
||||
var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class);
|
||||
verify(orderHistories).insert(history.capture());
|
||||
assertTrue(history.getValue().getRemark().endsWith("; 地址不完整"));
|
||||
verifyNoInteractions(inventory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() {
|
||||
WarehouseTransferOrder transfer = transfer("APPROVED");
|
||||
|
||||
@@ -57,14 +57,11 @@ public class InternalOrderCargoController {
|
||||
}
|
||||
var order = orders.findOwned(tenantId, orderId);
|
||||
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
|
||||
String latestInternalNote = orders.findNotes(tenantId, orderId).stream()
|
||||
.map(com.baiyue.order.entity.OrderInternalNoteEntity::getContent)
|
||||
.filter(java.util.Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty())
|
||||
.findFirst().orElse(null);
|
||||
return ApiResponse.success(new CargoBinding(cargo.getId(), orderId, cargo.getItemNo(),
|
||||
cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm(),
|
||||
cargo.getSkuNo(), cargo.getCargoNameEn(), cargo.getUnitsPerPackage(), cargo.getTotalQuantity(),
|
||||
cargo.getNetWeightKg(), cargo.getCargoRemark(), order.getCustomerRemark(), latestInternalNote));
|
||||
cargo.getNetWeightKg(), cargo.getCargoRemark(), order.getCustomerRemark(), order.getInternalRemark(),
|
||||
order.getWarehouseRemark(), order.getCustomerServiceRemark()));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{orderId}")
|
||||
@@ -167,12 +164,13 @@ public class InternalOrderCargoController {
|
||||
java.math.BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
|
||||
Integer unitsPerPackage, Integer totalQuantity,
|
||||
java.math.BigDecimal netWeightKg, String cargoRemark,
|
||||
String customerRemark, String latestInternalNote) {
|
||||
String customerRemark, String latestInternalNote,
|
||||
String warehouseRemark, String customerServiceRemark) {
|
||||
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg,
|
||||
java.math.BigDecimal originalVolumeCbm) {
|
||||
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
|
||||
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null);
|
||||
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg,
|
||||
@@ -181,7 +179,7 @@ public class InternalOrderCargoController {
|
||||
String cargoRemark) {
|
||||
this(cargoId, orderId, itemNo, cargoName, originalPackageCount, originalGrossWeightKg,
|
||||
originalVolumeCbm, skuNo, cargoNameEn, unitsPerPackage, totalQuantity, netWeightKg,
|
||||
cargoRemark, null, null);
|
||||
cargoRemark, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -282,23 +282,6 @@ public class PlatformFirstLegOrderController {
|
||||
return transit(user, tenantId, id, OrderStatus.COMPLETED, null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Add internal note / 添加内部备注")
|
||||
@Operation(summary = "Add internal note / 添加内部备注", description = "Adds a platform-only internal note to the target tenant order. / 为目标租户订单添加仅平台可见的内部备注。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Note added / 备注添加成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限"),
|
||||
})
|
||||
@PostMapping("/{id}/internal-notes")
|
||||
@PreAuthorize("hasAuthority('platform:order:note')")
|
||||
public ApiResponse<Long> note(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.NoteRequest request) {
|
||||
return ApiResponse.success(service.addNote(
|
||||
OrderScopeGuard.platform(user, tenantId), user.userId(), id, request.content()
|
||||
));
|
||||
}
|
||||
|
||||
/** 仅履约权限可登记快递信息,商户端保持只读。 */
|
||||
@PutMapping("/{id}/express")
|
||||
@PreAuthorize("hasAuthority('platform:order:fulfillment')")
|
||||
|
||||
@@ -112,16 +112,16 @@ public class PlatformUnifiedOrderController {
|
||||
return ApiResponse.success(splitService.containerSlices(containerBookingId, tenantId));
|
||||
}
|
||||
|
||||
@PostMapping("/{orderId}/internal-notes")
|
||||
@BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#orderId", description = "Add internal note / 添加内部备注")
|
||||
@PutMapping("/{orderId}/remarks")
|
||||
@BusinessAudit(event = "ORDER_REMARKS_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#orderId", description = "更新订单协作备注")
|
||||
@PreAuthorize("hasAuthority('platform:order:note')")
|
||||
@Operation(summary = "添加订单内部备注", description = "添加仅平台内部可见的订单备注。")
|
||||
public ApiResponse<Long> addInternalNote(
|
||||
@Operation(summary = "更新订单协作备注", description = "客户备注只读;平台可更新内部备注、仓库备注和客服备注。")
|
||||
public ApiResponse<OrderRequests.RemarksUpdateResponse> updateRemarks(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long orderId,
|
||||
@Valid @RequestBody OrderRequests.NoteRequest request) {
|
||||
@Valid @RequestBody OrderRequests.RemarksUpdateRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(orderService.addNote(scopedTenant, user.userId(), orderId, request.content()));
|
||||
return ApiResponse.success(orderService.updateRemarks(scopedTenant, user.userId(), orderId, request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,12 +316,17 @@ public final class OrderRequests {
|
||||
}
|
||||
}
|
||||
|
||||
@Schema(name = "OrderInternalNoteRequest", description = "Platform internal order note / 平台内部订单备注")
|
||||
public record NoteRequest(
|
||||
@Schema(description = "Internal note content / 内部备注内容", example = "Customer confirmed the shipping schedule / 客户已确认船期", maxLength = 2000, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 2000) String content
|
||||
) {
|
||||
}
|
||||
@Schema(name = "OrderRemarksUpdateRequest", description = "平台订单协作备注更新")
|
||||
public record RemarksUpdateRequest(
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 1000) String internalRemark,
|
||||
@Size(max = 1000) String warehouseRemark,
|
||||
@Size(max = 1000) String customerServiceRemark
|
||||
) { }
|
||||
|
||||
public record RemarksUpdateResponse(Integer version, String customerRemark,
|
||||
String internalRemark, String warehouseRemark,
|
||||
String customerServiceRemark) { }
|
||||
|
||||
public record ExpressRequest(
|
||||
@NotBlank @Size(max = 64)
|
||||
|
||||
@@ -35,7 +35,7 @@ public final class UnifiedOrderResponse {
|
||||
MeasurementSummary measurement, PreloadSummary preload,
|
||||
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
|
||||
CoLoadGroupSummary coLoadGroup,
|
||||
String customerRemark, String latestInternalNote,
|
||||
String customerRemark, String latestInternalNote, String warehouseRemark, String customerServiceRemark,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime requestedPickupAt
|
||||
) {}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ public class OrderEntity extends BaseEntity {
|
||||
private LocalDateTime expectedArrivalAt;
|
||||
private BigDecimal estimatedVolumeCbm;
|
||||
private String customerRemark;
|
||||
private String internalRemark;
|
||||
private String warehouseRemark;
|
||||
private String customerServiceRemark;
|
||||
private String pickupMethod;
|
||||
private LocalDateTime requestedPickupAt;
|
||||
private Long receivingWarehouseId;
|
||||
|
||||
@@ -117,6 +117,21 @@ public class OrderRepository {
|
||||
.set(OrderCargoEntity::getUpdatedAt, cargo.getUpdatedAt())
|
||||
.setSql("version = version + 1"));
|
||||
}
|
||||
|
||||
public int updateRemarks(long tenantId, long orderId, int expectedVersion, long operatorId,
|
||||
String internalRemark, String warehouseRemark, String customerServiceRemark) {
|
||||
return orderMapper.update(null, new LambdaUpdateWrapper<OrderEntity>()
|
||||
.eq(OrderEntity::getTenantId, tenantId)
|
||||
.eq(OrderEntity::getId, orderId)
|
||||
.eq(OrderEntity::getVersion, expectedVersion)
|
||||
.eq(OrderEntity::getDeleted, false)
|
||||
.set(OrderEntity::getInternalRemark, internalRemark)
|
||||
.set(OrderEntity::getWarehouseRemark, warehouseRemark)
|
||||
.set(OrderEntity::getCustomerServiceRemark, customerServiceRemark)
|
||||
.set(OrderEntity::getUpdatedBy, operatorId)
|
||||
.set(OrderEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("version = version + 1"));
|
||||
}
|
||||
public void insertCargo(OrderCargoEntity cargo) { cargoMapper.insert(cargo); }
|
||||
public void insertParty(OrderPartyEntity party) { partyMapper.insert(party); }
|
||||
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
|
||||
|
||||
@@ -268,11 +268,29 @@ public class OrderApplicationService {
|
||||
return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id),
|
||||
List.of(), repository.findHistory(tenantId, id));
|
||||
}
|
||||
public Long addNote(long tenantId, long userId, long id, String content) {
|
||||
require(tenantId, id);
|
||||
OrderInternalNoteEntity note = new OrderInternalNoteEntity();
|
||||
note.setTenantId(tenantId); note.setOrderId(id); note.setContent(content);
|
||||
note.setCreatedBy(userId); note.setUpdatedBy(userId); repository.insertNote(note); return note.getId();
|
||||
@Transactional
|
||||
public OrderRequests.RemarksUpdateResponse updateRemarks(long tenantId, long userId, long id,
|
||||
OrderRequests.RemarksUpdateRequest request) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
String internalRemark = normalizeRemark(request.internalRemark());
|
||||
String warehouseRemark = normalizeRemark(request.warehouseRemark());
|
||||
String customerServiceRemark = normalizeRemark(request.customerServiceRemark());
|
||||
if (repository.updateRemarks(tenantId, id, request.expectedVersion(), userId,
|
||||
internalRemark, warehouseRemark, customerServiceRemark) != 1) {
|
||||
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订单备注已被其他人修改,请刷新后重试");
|
||||
}
|
||||
if (!java.util.Objects.equals(order.getInternalRemark(), internalRemark) && internalRemark != null) {
|
||||
OrderInternalNoteEntity note = new OrderInternalNoteEntity();
|
||||
note.setTenantId(tenantId); note.setOrderId(id); note.setContent(internalRemark);
|
||||
note.setCreatedBy(userId); note.setUpdatedBy(userId); repository.insertNote(note);
|
||||
}
|
||||
return new OrderRequests.RemarksUpdateResponse(order.getVersion() + 1, order.getCustomerRemark(),
|
||||
internalRemark, warehouseRemark, customerServiceRemark);
|
||||
}
|
||||
|
||||
private static String normalizeRemark(String value) {
|
||||
if (value == null || value.trim().isEmpty()) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderInternalNoteEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import feign.FeignException;
|
||||
@@ -66,11 +65,10 @@ public class UnifiedOrderQueryService {
|
||||
List<OrderEntity> selected = orderNos.stream().map(orderByNo::get).filter(Objects::nonNull).toList();
|
||||
List<Long> orderIds = selected.stream().map(OrderEntity::getId).toList();
|
||||
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(null, orderIds);
|
||||
Map<Long, String> latestNotes = latestNotes(null, orderIds);
|
||||
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(null, selected);
|
||||
Map<Long, Integer> childCounts = childCounts(selected);
|
||||
return selected.stream().map(order -> row(order, inboundByOrder.get(order.getId()),
|
||||
latestNotes.get(order.getId()), childCounts, cargoSummaries.get(order.getId()), null)).toList();
|
||||
order.getInternalRemark(), childCounts, cargoSummaries.get(order.getId()), null)).toList();
|
||||
}
|
||||
|
||||
private PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query,
|
||||
@@ -89,13 +87,12 @@ public class UnifiedOrderQueryService {
|
||||
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus())
|
||||
? optionalSummaries(tenantId, sourceOrderIds)
|
||||
: summaries(tenantId, sourceOrderIds);
|
||||
Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
|
||||
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, source.records());
|
||||
Map<Long, Integer> childCounts = childCounts(source.records());
|
||||
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder = coLoadGroups(
|
||||
source.records().stream().map(OrderEntity::getId).toList(), includeCoLoadGroups);
|
||||
List<UnifiedOrderResponse.Row> rows = source.records().stream()
|
||||
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()),
|
||||
.map(order -> row(order, inboundByOrder.get(order.getId()), order.getInternalRemark(),
|
||||
childCounts, cargoSummaries.get(order.getId()), coLoadByOrder.get(order.getId()))).toList();
|
||||
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
@@ -117,7 +114,7 @@ public class UnifiedOrderQueryService {
|
||||
orders.findHistory(tenantId, orderId));
|
||||
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
|
||||
? optionalFirstlegDetail(tenantId, orderId) : firstlegDetail(tenantId, orderId);
|
||||
String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent();
|
||||
String latestNote = order.getInternalRemark();
|
||||
String creatorName = orderDetail.statusHistory().stream()
|
||||
.filter(item -> "CREATE".equals(item.getAction()))
|
||||
.map(item -> item.getOperatorName())
|
||||
@@ -166,14 +163,13 @@ public class UnifiedOrderQueryService {
|
||||
List<Long> orderIds = summaries.stream().map(item -> item.inbound().orderId()).toList();
|
||||
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
|
||||
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
|
||||
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
|
||||
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, List.copyOf(orderById.values()));
|
||||
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
|
||||
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder = coLoadGroups(
|
||||
orderIds, includeCoLoadGroups);
|
||||
List<UnifiedOrderResponse.Row> records = summaries.stream()
|
||||
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()),
|
||||
latestNotes.get(summary.inbound().orderId()), childCounts,
|
||||
orderById.get(summary.inbound().orderId()) == null ? null : orderById.get(summary.inbound().orderId()).getInternalRemark(), childCounts,
|
||||
cargoSummaries.get(summary.inbound().orderId()),
|
||||
coLoadByOrder.get(summary.inbound().orderId())))
|
||||
.toList();
|
||||
@@ -233,11 +229,6 @@ public class UnifiedOrderQueryService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
private Map<Long, String> latestNotes(Long tenantId, List<Long> orderIds) {
|
||||
return orders.findNotesByOrderIds(tenantId, orderIds).stream().collect(Collectors.toMap(
|
||||
OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest));
|
||||
}
|
||||
|
||||
private Map<Long, CargoSummary> cargoSummaries(Long tenantId, List<OrderEntity> pageOrders) {
|
||||
if (pageOrders.isEmpty()) return Map.of();
|
||||
List<Long> orderIds = pageOrders.stream().map(OrderEntity::getId).toList();
|
||||
@@ -338,7 +329,7 @@ public class UnifiedOrderQueryService {
|
||||
null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
|
||||
StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo),
|
||||
null, null, null, null, null, coLoadGroup,
|
||||
order.getCustomerRemark(), latestInternalNote,
|
||||
order.getCustomerRemark(), latestInternalNote, order.getWarehouseRemark(), order.getCustomerServiceRemark(),
|
||||
order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt());
|
||||
}
|
||||
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
|
||||
@@ -365,6 +356,7 @@ public class UnifiedOrderQueryService {
|
||||
StringUtils.hasText(creatorName) ? creatorName : inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound),
|
||||
preload(summary.preload()), loading(summary.loading()),
|
||||
delivery(summary.delivery()), transport(inbound), coLoadGroup, order.getCustomerRemark(), latestInternalNote,
|
||||
order.getWarehouseRemark(), order.getCustomerServiceRemark(),
|
||||
order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt());
|
||||
}
|
||||
|
||||
@@ -394,6 +386,7 @@ public class UnifiedOrderQueryService {
|
||||
measurement(inbound), preload(summary.preload()), loading(summary.loading()),
|
||||
delivery(summary.delivery()), transport(inbound), coLoadGroup,
|
||||
order == null ? null : order.getCustomerRemark(), latestInternalNote,
|
||||
order == null ? null : order.getWarehouseRemark(), order == null ? null : order.getCustomerServiceRemark(),
|
||||
inbound.createdAt(), inbound.updatedAt(), inbound.requestedPickupAt());
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间',
|
||||
estimated_volume_cbm DECIMAL(18,4) NULL COMMENT '平台运营预估体积(立方米),独立于客户下单及实测体积',
|
||||
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
|
||||
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注',
|
||||
customer_service_remark VARCHAR(1000) NULL COMMENT '客服备注',
|
||||
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
|
||||
requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间',
|
||||
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID',
|
||||
@@ -66,6 +69,33 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
|
||||
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
|
||||
|
||||
SET @order_internal_remark_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_order' AND table_name='ord_order' AND column_name='internal_remark'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE baiyue_order.ord_order ADD COLUMN internal_remark VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER customer_remark'
|
||||
);
|
||||
PREPARE order_internal_remark_stmt FROM @order_internal_remark_sql;
|
||||
EXECUTE order_internal_remark_stmt;
|
||||
DEALLOCATE PREPARE order_internal_remark_stmt;
|
||||
|
||||
SET @order_warehouse_remark_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_order' AND table_name='ord_order' AND column_name='warehouse_remark'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE baiyue_order.ord_order ADD COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT ''仓库备注'' AFTER internal_remark'
|
||||
);
|
||||
PREPARE order_warehouse_remark_stmt FROM @order_warehouse_remark_sql;
|
||||
EXECUTE order_warehouse_remark_stmt;
|
||||
DEALLOCATE PREPARE order_warehouse_remark_stmt;
|
||||
|
||||
SET @order_customer_service_remark_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_order' AND table_name='ord_order' AND column_name='customer_service_remark'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE baiyue_order.ord_order ADD COLUMN customer_service_remark VARCHAR(1000) NULL COMMENT ''客服备注'' AFTER warehouse_remark'
|
||||
);
|
||||
PREPARE order_customer_service_remark_stmt FROM @order_customer_service_remark_sql;
|
||||
EXECUTE order_customer_service_remark_stmt;
|
||||
DEALLOCATE PREPARE order_customer_service_remark_stmt;
|
||||
|
||||
SET @order_fulfillment_type_sql = IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
|
||||
@@ -267,10 +267,10 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UnifiedOrderDetailEnvelope'}
|
||||
'403': {description: 需要平台身份和 platform:order:list 权限}
|
||||
/api/platform/v1/orders/{orderId}/internal-notes:
|
||||
post:
|
||||
summary: 添加订单内部备注
|
||||
description: 添加仅平台内部可见的订单备注,需要 platform:order:note 权限。
|
||||
/api/platform/v1/orders/{orderId}/remarks:
|
||||
put:
|
||||
summary: 更新订单协作备注
|
||||
description: 客户备注只读;需要 platform:order:note 权限,可更新或清空内部备注、仓库备注和客服备注。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedOrderId'
|
||||
- $ref: '#/components/parameters/UnifiedTenantId'
|
||||
@@ -280,9 +280,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [content]
|
||||
required: [expectedVersion]
|
||||
properties:
|
||||
content: {type: string, minLength: 1, maxLength: 2000}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
internalRemark: {type: string, nullable: true, maxLength: 1000}
|
||||
warehouseRemark: {type: string, nullable: true, maxLength: 1000}
|
||||
customerServiceRemark: {type: string, nullable: true, maxLength: 1000}
|
||||
responses:
|
||||
'200': {description: 备注添加成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要 platform:order:note 权限}
|
||||
@@ -949,6 +952,8 @@ components:
|
||||
coLoadGroup: {$ref: '#/components/schemas/UnifiedCoLoadGroupSummary'}
|
||||
customerRemark: {type: string, nullable: true}
|
||||
latestInternalNote: {type: string, nullable: true, description: 客户详情不返回平台内部备注}
|
||||
warehouseRemark: {type: string, nullable: true}
|
||||
customerServiceRemark: {type: string, nullable: true}
|
||||
createdAt: {type: string, format: date-time, nullable: true}
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
UnifiedOrderRelation:
|
||||
|
||||
+5
-3
@@ -3,7 +3,6 @@ package com.baiyue.order.controller;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderInternalNoteEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.PrintableInboundSnapshotService;
|
||||
@@ -50,9 +49,10 @@ class InternalOrderCargoControllerTest {
|
||||
cargo.setCargoRemark("内部货物备注");
|
||||
when(orders.findCargoInheritedByOrder(11L, 21L, 31L)).thenReturn(cargo);
|
||||
OrderEntity order = new OrderEntity(); order.setId(21L); order.setCustomerRemark("客户订单备注");
|
||||
OrderInternalNoteEntity note = new OrderInternalNoteEntity(); note.setContent("最新内部备注");
|
||||
order.setInternalRemark("最新内部备注");
|
||||
order.setWarehouseRemark("仓库备注");
|
||||
order.setCustomerServiceRemark("客服备注");
|
||||
when(orders.findOwned(11L, 21L)).thenReturn(order);
|
||||
when(orders.findNotes(11L, 21L)).thenReturn(List.of(note));
|
||||
|
||||
var response = controller.detail("internal-token", 11L, 21L, 31L);
|
||||
|
||||
@@ -67,6 +67,8 @@ class InternalOrderCargoControllerTest {
|
||||
assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注");
|
||||
assertThat(response.data().customerRemark()).isEqualTo("客户订单备注");
|
||||
assertThat(response.data().latestInternalNote()).isEqualTo("最新内部备注");
|
||||
assertThat(response.data().warehouseRemark()).isEqualTo("仓库备注");
|
||||
assertThat(response.data().customerServiceRemark()).isEqualTo("客服备注");
|
||||
verify(orders).findCargoInheritedByOrder(11L, 21L, 31L);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,9 +64,9 @@ class OrderControllerPermissionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedPlatformInternalNoteRequiresNotePermission() throws Exception {
|
||||
assertAuthority(PlatformUnifiedOrderController.class, "addInternalNote", "hasAuthority('platform:order:note')",
|
||||
AuthenticatedUser.class, long.class, long.class, OrderRequests.NoteRequest.class);
|
||||
void unifiedPlatformRemarksRequireNotePermission() throws Exception {
|
||||
assertAuthority(PlatformUnifiedOrderController.class, "updateRemarks", "hasAuthority('platform:order:note')",
|
||||
AuthenticatedUser.class, long.class, long.class, OrderRequests.RemarksUpdateRequest.class);
|
||||
}
|
||||
|
||||
private void assertAuthority(Class<?> controller, String methodName, String expected, Class<?>... parameterTypes)
|
||||
|
||||
@@ -697,6 +697,48 @@ class OrderApplicationServiceStateTest {
|
||||
.isEqualTo("HDHK26081410000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesThreeEditableRemarksAndKeepsCustomerRemarkReadOnly() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(77L);
|
||||
order.setTenantId(10001L);
|
||||
order.setVersion(3);
|
||||
order.setCustomerRemark("客户原始备注");
|
||||
order.setInternalRemark("旧内部备注");
|
||||
when(repository.findOwned(10001L, 77L)).thenReturn(order);
|
||||
when(repository.updateRemarks(10001L, 77L, 3, 9L, "新内部备注", "仓库备注", "客服备注")).thenReturn(1);
|
||||
|
||||
var response = service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.updateRemarks(10001L, 9L, 77L,
|
||||
new OrderRequests.RemarksUpdateRequest(3, " 新内部备注 ", "仓库备注", "客服备注"));
|
||||
|
||||
assertThat(response.version()).isEqualTo(4);
|
||||
assertThat(response.customerRemark()).isEqualTo("客户原始备注");
|
||||
assertThat(response.internalRemark()).isEqualTo("新内部备注");
|
||||
assertThat(response.warehouseRemark()).isEqualTo("仓库备注");
|
||||
assertThat(response.customerServiceRemark()).isEqualTo("客服备注");
|
||||
verify(repository).insertNote(argThat(note -> note.getTenantId().equals(10001L)
|
||||
&& note.getOrderId().equals(77L) && note.getContent().equals("新内部备注")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRemarkUpdateWhenOrderVersionChanged() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(77L);
|
||||
order.setTenantId(10001L);
|
||||
order.setVersion(4);
|
||||
when(repository.findOwned(10001L, 77L)).thenReturn(order);
|
||||
when(repository.updateRemarks(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(), any())).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.updateRemarks(10001L, 9L, 77L, new OrderRequests.RemarksUpdateRequest(3, null, null, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("请刷新后重试");
|
||||
verify(repository, never()).insertNote(any());
|
||||
}
|
||||
|
||||
private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility,
|
||||
OrderMasterDataGuard masterData) {
|
||||
OrderApplicationService service = springService;
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderInternalNoteEntity;
|
||||
import com.baiyue.order.entity.OrderStatusHistoryEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
@@ -58,14 +57,13 @@ class UnifiedOrderQueryServiceTest {
|
||||
OrderEntity order = order(OrderStatus.SUBMITTED);
|
||||
order.setInboundCode("RK-77");
|
||||
order.setCustomerRemark("客户要求提货前联系");
|
||||
OrderInternalNoteEntity note = new OrderInternalNoteEntity();
|
||||
note.setOrderId(77L);
|
||||
note.setContent("客服已确认提货时间");
|
||||
order.setInternalRemark("客服已确认提货时间");
|
||||
order.setWarehouseRemark("仓库请优先处理");
|
||||
order.setCustomerServiceRemark("客服已回访");
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
|
||||
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
|
||||
when(firstleg.summaries(eq("internal-token"), any()))
|
||||
.thenReturn(ApiResponse.success(List.of()));
|
||||
when(orders.findNotesByOrderIds(10001L, List.of(77L))).thenReturn(List.of(note));
|
||||
|
||||
var result = service.page(10001L, query);
|
||||
|
||||
@@ -79,6 +77,8 @@ class UnifiedOrderQueryServiceTest {
|
||||
assertThat(row.currentHoldReason()).isNull();
|
||||
assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系");
|
||||
assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间");
|
||||
assertThat(row.warehouseRemark()).isEqualTo("仓库请优先处理");
|
||||
assertThat(row.customerServiceRemark()).isEqualTo("客服已回访");
|
||||
assertThat(row.relation()).isNull();
|
||||
});
|
||||
verify(firstleg).summaries(eq("internal-token"), any());
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
||||
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
|
||||
- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V1`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板每托固定一页,展示托盘 Code 128 一维码、最多两行入仓码和最多 34 行完整 SKU 归托信息,不展示作业状态。新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
|
||||
- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板采用黑白打印版,每托固定一页,展示托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。新尺寸或布局必须使用新模板编码。
|
||||
|
||||
## 快速索引
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
- 服务发现名:`print-service`。
|
||||
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
||||
- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V1`,每托一页 A4,包含托盘 Code 128 一维码、最多两行入仓码、总箱数和最多 34 行 SKU/商品名称/箱数/实点数量,不展示作业状态。
|
||||
- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V2`,采用黑白打印版,每托一页 A4,包含托盘 Code 128 一维码、目的仓、托盘序号、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。
|
||||
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
|
||||
- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。
|
||||
- 本地直连:`http://127.0.0.1:9207`。
|
||||
|
||||
Binary file not shown.
@@ -5,7 +5,7 @@ import java.util.List;
|
||||
|
||||
/** 一件代发托盘 A4 信息单的内部渲染契约。 */
|
||||
public final class DropshipPalletA4PdfDtos {
|
||||
public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V1";
|
||||
public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V2";
|
||||
|
||||
private DropshipPalletA4PdfDtos() {}
|
||||
|
||||
@@ -14,10 +14,14 @@ public final class DropshipPalletA4PdfDtos {
|
||||
|
||||
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
|
||||
|
||||
public record Document(String palletNo, String inboundCodes, int cartonCount,
|
||||
int skuCount, BigDecimal weightKg, BigDecimal volumeCbm,
|
||||
String barcodeContent, List<SkuRow> skuRows) {}
|
||||
public record Document(String palletNo, String destinationWarehouse,
|
||||
int palletSequence, int totalPallets,
|
||||
String barcodeContent, List<InboundRow> inboundRows,
|
||||
List<SkuRow> skuRows) {}
|
||||
|
||||
public record SkuRow(String inboundCode, String skuCode, String cargoName,
|
||||
int cartonCount, int skuQuantity) {}
|
||||
public record InboundRow(String inboundCode, String customerNickname,
|
||||
int cartonCount, int itemQuantity) {}
|
||||
|
||||
public record SkuRow(String skuCode, int cartonCount,
|
||||
int itemQuantity, String unitsPerCarton) {}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,17 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 每个托盘一页 A4,Code 128 托盘码与完整 SKU 归托信息同页展示。 */
|
||||
/** 每个托盘一页 A4:托盘码、目的仓、入仓码汇总和 SKU 汇总。 */
|
||||
@Component
|
||||
public class DropshipPalletA4Renderer {
|
||||
private static final float[] COLS = {8, 18, 57, 98, 161, 181, 202};
|
||||
private static final String[] HEADERS = {"序号", "入仓码", "SKU", "商品名称", "箱数", "实点数量"};
|
||||
private static final double TABLE_TOP = 105;
|
||||
private static final double HEADER_HEIGHT = 8;
|
||||
private static final double REGULAR_ROW_HEIGHT = 7;
|
||||
private static final double DENSE_ROW_HEIGHT = 5.2;
|
||||
private static final float BARCODE_WIDTH_PT = PdfUnits.mmToPt(158);
|
||||
private static final float BARCODE_HEIGHT_PT = PdfUnits.mmToPt(22);
|
||||
private static final Color INK = Color.BLACK;
|
||||
private static final Color LIGHT_GRAY = new Color(242, 242, 242);
|
||||
private static final Color TABLE_HEADER = new Color(232, 232, 232);
|
||||
private static final float[] INBOUND_COLS = {18, 27, 79, 146, 169, 192};
|
||||
private static final String[] INBOUND_HEADERS = {"#", "入仓码", "客户", "箱数", "件数"};
|
||||
private static final float[] SKU_COLS = {18, 132, 154, 176, 192};
|
||||
private static final String[] SKU_HEADERS = {"SKU", "箱数", "件数", "每箱件数"};
|
||||
|
||||
@Resource private PdfBarcodeRenderer barcodeRenderer;
|
||||
|
||||
public void renderPage(PDDocument pdf, PDFont font, DropshipPalletA4PdfDtos.Document data,
|
||||
@@ -32,93 +32,144 @@ public class DropshipPalletA4Renderer {
|
||||
pdf.addPage(page);
|
||||
PdfUnits units = new PdfUnits(297);
|
||||
try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
|
||||
stream.setNonStrokingColor(Color.BLACK);
|
||||
stream.setLineWidth(PdfUnits.mmToPt(.3));
|
||||
centered(stream, units, font, "一件代发托盘信息单", 15, 105, 13);
|
||||
centered(stream, units, font, data.palletNo(), 22, 105, 25);
|
||||
barcode(stream, units, barcodeMatrix);
|
||||
fill(stream, units, new Color(245, 247, 250), 8, 58, 202, 94);
|
||||
inboundCodes(stream, units, font, data.inboundCodes());
|
||||
line(stream, units, 8, 78, 202, 78);
|
||||
metric(stream, units, font, "总箱数", Integer.toString(data.cartonCount()), 8, 56.5);
|
||||
metric(stream, units, font, "SKU 数", Integer.toString(data.skuCount()), 56.5, 105);
|
||||
metric(stream, units, font, "重量", decimal(data.weightKg()) + " kg", 105, 153.5);
|
||||
metric(stream, units, font, "体积", decimal(data.volumeCbm()) + " m³", 153.5, 202);
|
||||
line(stream, units, 56.5, 78, 56.5, 94);
|
||||
line(stream, units, 105, 78, 105, 94);
|
||||
line(stream, units, 153.5, 78, 153.5, 94);
|
||||
line(stream, units, 8, 99, 202, 99);
|
||||
text(stream, units, font, "SKU 明细", 11, 8, 104);
|
||||
table(stream, units, font, data.skuRows());
|
||||
stream.setLineWidth(PdfUnits.mmToPt(.25));
|
||||
border(stream, units);
|
||||
colorText(stream, units, font, "一件代发备货托", 14, 18, 22, INK);
|
||||
rightText(stream, units, font,
|
||||
"第 " + data.palletSequence() + " 托 / 共 " + data.totalPallets() + " 托",
|
||||
10, 192, 22, new Color(75, 75, 75));
|
||||
|
||||
barcodeRenderer.draw(stream, barcodeMatrix, PdfUnits.mmToPt(18), units.topDownMmToPdfY(51),
|
||||
PdfUnits.mmToPt(80), PdfUnits.mmToPt(22));
|
||||
fittedText(stream, units, font, data.palletNo(), 25, 105, 44, 87, Color.BLACK);
|
||||
|
||||
fill(stream, units, LIGHT_GRAY, 10, 57, 200, 82);
|
||||
colorText(stream, units, font, "目的仓", 7.5f, 18, 66, INK);
|
||||
fittedText(stream, units, font, data.destinationWarehouse(), 20, 18, 77, 174, INK);
|
||||
|
||||
int rowCount = data.inboundRows().size() + 1 + Math.max(1, data.skuRows().size());
|
||||
double rowHeight = rowCount > 36 ? 2.9 : rowCount > 24 ? 3.6 : 6.0;
|
||||
float rowFont = rowCount > 36 ? 4.4f : rowCount > 24 ? 5.0f : 7.0f;
|
||||
|
||||
int inboundCartons = data.inboundRows().stream().mapToInt(DropshipPalletA4PdfDtos.InboundRow::cartonCount).sum();
|
||||
int inboundItems = data.inboundRows().stream().mapToInt(DropshipPalletA4PdfDtos.InboundRow::itemQuantity).sum();
|
||||
colorText(stream, units, font,
|
||||
"入仓码信息 · 共 " + data.inboundRows().size() + " 票",
|
||||
9, 18, 92, INK);
|
||||
double inboundBottom = inboundTable(stream, units, font, data.inboundRows(),
|
||||
inboundCartons, inboundItems, 97, rowHeight, rowFont);
|
||||
|
||||
double skuTitleY = inboundBottom + 8;
|
||||
colorText(stream, units, font, "SKU 数:" + data.skuRows().size(), 9, 18, skuTitleY, new Color(70, 70, 70));
|
||||
skuTable(stream, units, font, data.skuRows(), skuTitleY + 5, rowHeight, rowFont);
|
||||
|
||||
line(stream, units, 10, 280, 200, 280);
|
||||
colorText(stream, units, font, "拆托 / 合托后此标作废,须重打", 8.5f, 18, 288, INK);
|
||||
}
|
||||
}
|
||||
|
||||
private void barcode(PDPageContentStream stream, PdfUnits units, BitMatrix matrix) throws IOException {
|
||||
float left = PdfUnits.mmToPt(26);
|
||||
float bottom = units.topDownMmToPdfY(54);
|
||||
barcodeRenderer.draw(stream, matrix, left, bottom, BARCODE_WIDTH_PT, BARCODE_HEIGHT_PT);
|
||||
private double inboundTable(PDPageContentStream s, PdfUnits u, PDFont f,
|
||||
List<DropshipPalletA4PdfDtos.InboundRow> rows,
|
||||
int totalCartons, int totalItems, double top,
|
||||
double rowHeight, float rowFont) throws IOException {
|
||||
double headerHeight = 7;
|
||||
double bottom = top + headerHeight + (rows.size() + 1) * rowHeight;
|
||||
tableGrid(s, u, INBOUND_COLS, top, bottom, headerHeight, rows.size() + 1, rowHeight);
|
||||
tableHeaders(s, u, f, INBOUND_COLS, INBOUND_HEADERS, top, headerHeight);
|
||||
for (int index = 0; index < rows.size(); index++) {
|
||||
var row = rows.get(index);
|
||||
String[] values = {Integer.toString(index + 1), row.inboundCode(), row.customerNickname(),
|
||||
Integer.toString(row.cartonCount()), Integer.toString(row.itemQuantity())};
|
||||
tableRow(s, u, f, INBOUND_COLS, values, top + headerHeight + index * rowHeight,
|
||||
rowHeight, rowFont);
|
||||
}
|
||||
String[] totals = {"计", "跨 " + rows.size() + " 票合计", "", Integer.toString(totalCartons),
|
||||
Integer.toString(totalItems)};
|
||||
tableRow(s, u, f, INBOUND_COLS, totals, top + headerHeight + rows.size() * rowHeight,
|
||||
rowHeight, rowFont);
|
||||
return bottom;
|
||||
}
|
||||
|
||||
private void table(PDPageContentStream stream, PdfUnits units, PDFont font,
|
||||
List<DropshipPalletA4PdfDtos.SkuRow> rows) throws IOException {
|
||||
private void skuTable(PDPageContentStream s, PdfUnits u, PDFont f,
|
||||
List<DropshipPalletA4PdfDtos.SkuRow> rows, double top,
|
||||
double rowHeight, float rowFont) throws IOException {
|
||||
double headerHeight = 7;
|
||||
int visibleRows = Math.max(1, rows.size());
|
||||
double rowHeight = rows.size() > 24 ? DENSE_ROW_HEIGHT : REGULAR_ROW_HEIGHT;
|
||||
float rowFontSize = rows.size() > 24 ? 5.3f : 6.4f;
|
||||
double headerBottom = TABLE_TOP + HEADER_HEIGHT;
|
||||
double bottom = headerBottom + visibleRows * rowHeight;
|
||||
fill(stream, units, new Color(235, 239, 245), COLS[0], TABLE_TOP, COLS[COLS.length - 1], headerBottom);
|
||||
for (float x : COLS) line(stream, units, x, TABLE_TOP, x, bottom);
|
||||
for (int boundary = 0; boundary <= visibleRows; boundary++) {
|
||||
line(stream, units, COLS[0], headerBottom + boundary * rowHeight,
|
||||
COLS[COLS.length - 1], headerBottom + boundary * rowHeight);
|
||||
}
|
||||
line(stream, units, COLS[0], TABLE_TOP, COLS[COLS.length - 1], TABLE_TOP);
|
||||
for (int column = 0; column < HEADERS.length; column++) {
|
||||
centeredCell(stream, units, font, HEADERS[column], 7.2f,
|
||||
COLS[column] + .5, COLS[column + 1] - .5, TABLE_TOP + 5.5);
|
||||
}
|
||||
double bottom = top + headerHeight + visibleRows * rowHeight;
|
||||
tableGrid(s, u, SKU_COLS, top, bottom, headerHeight, visibleRows, rowHeight);
|
||||
tableHeaders(s, u, f, SKU_COLS, SKU_HEADERS, top, headerHeight);
|
||||
if (rows.isEmpty()) {
|
||||
centeredCell(stream, units, font, "暂无已归托 SKU", 7.2f, COLS[0], COLS[COLS.length - 1], headerBottom + 4.8);
|
||||
tableRow(s, u, f, SKU_COLS, new String[]{"暂无已归托 SKU", "", "", ""},
|
||||
top + headerHeight, rowHeight, rowFont);
|
||||
return;
|
||||
}
|
||||
for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
|
||||
DropshipPalletA4PdfDtos.SkuRow row = rows.get(rowIndex);
|
||||
String[] values = {Integer.toString(rowIndex + 1), row.inboundCode(), row.skuCode(), row.cargoName(),
|
||||
Integer.toString(row.cartonCount()), Integer.toString(row.skuQuantity())};
|
||||
double baseline = headerBottom + rowIndex * rowHeight + rowHeight * .7;
|
||||
for (int column = 0; column < values.length; column++) {
|
||||
centeredCell(stream, units, font, values[column], rowFontSize,
|
||||
COLS[column] + .7, COLS[column + 1] - .7, baseline);
|
||||
}
|
||||
for (int index = 0; index < rows.size(); index++) {
|
||||
var row = rows.get(index);
|
||||
String[] values = {row.skuCode(), Integer.toString(row.cartonCount()),
|
||||
Integer.toString(row.itemQuantity()), row.unitsPerCarton()};
|
||||
tableRow(s, u, f, SKU_COLS, values, top + headerHeight + index * rowHeight,
|
||||
rowHeight, rowFont);
|
||||
}
|
||||
}
|
||||
|
||||
private String decimal(java.math.BigDecimal value) {
|
||||
return value == null ? "0" : value.stripTrailingZeros().toPlainString();
|
||||
}
|
||||
private void inboundCodes(PDPageContentStream s, PdfUnits u, PDFont f, String value) throws IOException {
|
||||
text(s, u, f, "入仓码:", 8, 14, 66);
|
||||
List<String> lines = wrap(f, value, 8, PdfUnits.mmToPt(163), 2);
|
||||
for (int index = 0; index < lines.size(); index++) {
|
||||
text(s, u, f, lines.get(index), 8, 33, 66 + index * 6);
|
||||
private void tableGrid(PDPageContentStream s, PdfUnits u, float[] columns, double top,
|
||||
double bottom, double headerHeight, int rows, double rowHeight) throws IOException {
|
||||
fill(s, u, TABLE_HEADER, columns[0], top, columns[columns.length - 1], top + headerHeight);
|
||||
for (float column : columns) line(s, u, column, top, column, bottom);
|
||||
line(s, u, columns[0], top, columns[columns.length - 1], top);
|
||||
line(s, u, columns[0], top + headerHeight, columns[columns.length - 1], top + headerHeight);
|
||||
for (int index = 1; index <= rows; index++) {
|
||||
line(s, u, columns[0], top + headerHeight + index * rowHeight,
|
||||
columns[columns.length - 1], top + headerHeight + index * rowHeight);
|
||||
}
|
||||
}
|
||||
private void metric(PDPageContentStream s, PdfUnits u, PDFont f, String label, String value,
|
||||
double x1, double x2) throws IOException {
|
||||
centeredCell(s, u, f, label, 7.2f, x1 + 1, x2 - 1, 84);
|
||||
centeredCell(s, u, f, value, 10.5f, x1 + 1, x2 - 1, 91);
|
||||
|
||||
private void tableHeaders(PDPageContentStream s, PdfUnits u, PDFont f, float[] columns,
|
||||
String[] headers, double top, double headerHeight) throws IOException {
|
||||
for (int index = 0; index < headers.length; index++) {
|
||||
centeredCell(s, u, f, headers[index], 7, columns[index] + .5,
|
||||
columns[index + 1] - .5, top + headerHeight * .68);
|
||||
}
|
||||
}
|
||||
private void centered(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
|
||||
double centerX, double y) throws IOException {
|
||||
float width = f.getStringWidth(value) / 1000 * size;
|
||||
text(s, u, f, value, size, centerX - width / PdfUnits.mmToPt(1) / 2, y);
|
||||
|
||||
private void tableRow(PDPageContentStream s, PdfUnits u, PDFont f, float[] columns,
|
||||
String[] values, double top, double height, float size) throws IOException {
|
||||
for (int index = 0; index < values.length; index++) {
|
||||
centeredCell(s, u, f, values[index], size, columns[index] + .7,
|
||||
columns[index + 1] - .7, top + height * .7);
|
||||
}
|
||||
}
|
||||
|
||||
private void border(PDPageContentStream s, PdfUnits u) throws IOException {
|
||||
s.setStrokingColor(INK);
|
||||
s.setLineWidth(PdfUnits.mmToPt(.55));
|
||||
s.addRect(PdfUnits.mmToPt(10), u.topDownMmToPdfY(290), PdfUnits.mmToPt(190), PdfUnits.mmToPt(280));
|
||||
s.stroke();
|
||||
s.setStrokingColor(new Color(140, 140, 140));
|
||||
s.setLineWidth(PdfUnits.mmToPt(.25));
|
||||
}
|
||||
|
||||
private void fittedText(PDPageContentStream s, PdfUnits u, PDFont f, String value,
|
||||
float preferredSize, double x, double y, double widthMm,
|
||||
Color color) throws IOException {
|
||||
float size = preferredSize;
|
||||
while (size > 9 && f.getStringWidth(value) / 1000 * size > PdfUnits.mmToPt(widthMm)) size -= .5f;
|
||||
colorText(s, u, f, fit(f, value, size, PdfUnits.mmToPt(widthMm)), size, x, y, color);
|
||||
}
|
||||
|
||||
private void rightText(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
|
||||
double right, double y, Color color) throws IOException {
|
||||
double widthMm = f.getStringWidth(value) / 1000 * size / PdfUnits.mmToPt(1);
|
||||
colorText(s, u, f, value, size, right - widthMm, y, color);
|
||||
}
|
||||
|
||||
private void centeredCell(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
|
||||
double x1, double x2, double y) throws IOException {
|
||||
String fitted = fit(f, value, size, PdfUnits.mmToPt(x2 - x1));
|
||||
float width = f.getStringWidth(fitted) / 1000 * size;
|
||||
text(s, u, f, fitted, size, x1 + (x2 - x1 - width / PdfUnits.mmToPt(1)) / 2, y);
|
||||
}
|
||||
|
||||
private String fit(PDFont font, String raw, float size, float maxWidth) throws IOException {
|
||||
String value = raw == null || raw.isBlank() ? "—" : raw;
|
||||
if (font.getStringWidth(value) / 1000 * size <= maxWidth) return value;
|
||||
@@ -127,26 +178,7 @@ public class DropshipPalletA4Renderer {
|
||||
while (end > 0 && font.getStringWidth(value.substring(0, end) + ellipsis) / 1000 * size > maxWidth) end--;
|
||||
return end == 0 ? ellipsis : value.substring(0, end) + ellipsis;
|
||||
}
|
||||
private List<String> wrap(PDFont font, String raw, float size, float maxWidth,
|
||||
int maxLines) throws IOException {
|
||||
String remaining = raw == null || raw.isBlank() ? "—" : raw;
|
||||
List<String> lines = new java.util.ArrayList<>();
|
||||
while (!remaining.isEmpty() && lines.size() < maxLines) {
|
||||
if (font.getStringWidth(remaining) / 1000 * size <= maxWidth) {
|
||||
lines.add(remaining);
|
||||
break;
|
||||
}
|
||||
int split = remaining.length();
|
||||
while (split > 0 && font.getStringWidth(remaining.substring(0, split)) / 1000 * size > maxWidth) split--;
|
||||
int delimiter = remaining.lastIndexOf('/', split - 1);
|
||||
if (delimiter > 0) split = delimiter + 1;
|
||||
String line = remaining.substring(0, Math.max(1, split));
|
||||
remaining = remaining.substring(Math.max(1, split));
|
||||
if (lines.size() == maxLines - 1 && !remaining.isEmpty()) line = fit(font, line + remaining, size, maxWidth);
|
||||
lines.add(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private void fill(PDPageContentStream s, PdfUnits u, Color color, double x1, double y1,
|
||||
double x2, double y2) throws IOException {
|
||||
s.setNonStrokingColor(color);
|
||||
@@ -154,14 +186,27 @@ public class DropshipPalletA4Renderer {
|
||||
s.fill();
|
||||
s.setNonStrokingColor(Color.BLACK);
|
||||
}
|
||||
|
||||
private void colorText(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
|
||||
double x, double y, Color color) throws IOException {
|
||||
s.setNonStrokingColor(color);
|
||||
text(s, u, f, value, size, x, y);
|
||||
s.setNonStrokingColor(Color.BLACK);
|
||||
}
|
||||
|
||||
private void text(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
|
||||
double x, double y) throws IOException {
|
||||
s.beginText(); s.setFont(f, size); s.newLineAtOffset(PdfUnits.mmToPt(x), u.topDownMmToPdfY(y));
|
||||
s.showText(value == null ? "—" : value); s.endText();
|
||||
s.beginText();
|
||||
s.setFont(f, size);
|
||||
s.newLineAtOffset(PdfUnits.mmToPt(x), u.topDownMmToPdfY(y));
|
||||
s.showText(value == null ? "—" : value);
|
||||
s.endText();
|
||||
}
|
||||
|
||||
private void line(PDPageContentStream s, PdfUnits u, double x1, double y1, double x2,
|
||||
double y2) throws IOException {
|
||||
s.moveTo(PdfUnits.mmToPt(x1), u.topDownMmToPdfY(y1));
|
||||
s.lineTo(PdfUnits.mmToPt(x2), u.topDownMmToPdfY(y2)); s.stroke();
|
||||
s.lineTo(PdfUnits.mmToPt(x2), u.topDownMmToPdfY(y2));
|
||||
s.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.springframework.stereotype.Service;
|
||||
public class DropshipPalletA4PdfService {
|
||||
private static final BigDecimal A4_WIDTH = new BigDecimal("210");
|
||||
private static final BigDecimal A4_HEIGHT = new BigDecimal("297");
|
||||
private static final int MAX_INBOUND_ROWS = 10;
|
||||
private static final int MAX_SKU_ROWS = 34;
|
||||
private static final Set<Character> FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
|
||||
|
||||
@@ -67,21 +68,28 @@ public class DropshipPalletA4PdfService {
|
||||
|
||||
private DropshipPalletA4PdfDtos.Document normalizeDocument(DropshipPalletA4PdfDtos.Document value, int index) {
|
||||
if (value == null) invalid("documents[" + index + "] is required");
|
||||
if (value.cartonCount() < 0 || value.skuCount() < 0 || negative(value.weightKg()) || negative(value.volumeCbm())) invalid("documents[" + index + "] totals are invalid");
|
||||
if (value.palletSequence() <= 0 || value.totalPallets() <= 0
|
||||
|| value.palletSequence() > value.totalPallets()) invalid("documents[" + index + "] pallet position is invalid");
|
||||
if (value.inboundRows() == null || value.inboundRows().isEmpty()
|
||||
|| value.inboundRows().size() > MAX_INBOUND_ROWS) invalid("documents[" + index + "].inboundRows exceeds the one-page A4 capacity");
|
||||
if (value.skuRows() == null || value.skuRows().size() > MAX_SKU_ROWS) invalid("documents[" + index + "].skuRows exceeds the one-page A4 capacity");
|
||||
List<DropshipPalletA4PdfDtos.InboundRow> inboundRows = new ArrayList<>();
|
||||
for (DropshipPalletA4PdfDtos.InboundRow row : value.inboundRows()) {
|
||||
if (row == null || row.cartonCount() < 0 || row.itemQuantity() < 0) invalid("inbound row is invalid");
|
||||
inboundRows.add(new DropshipPalletA4PdfDtos.InboundRow(text(row.inboundCode(), 80),
|
||||
text(row.customerNickname(), 80), row.cartonCount(), row.itemQuantity()));
|
||||
}
|
||||
List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
|
||||
for (DropshipPalletA4PdfDtos.SkuRow row : value.skuRows()) {
|
||||
if (row == null || row.cartonCount() < 0 || row.skuQuantity() < 0) invalid("SKU row is invalid");
|
||||
rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.inboundCode(), 80), text(row.skuCode(), 128),
|
||||
text(row.cargoName(), 200), row.cartonCount(), row.skuQuantity()));
|
||||
if (row == null || row.cartonCount() < 0 || row.itemQuantity() < 0) invalid("SKU row is invalid");
|
||||
rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.skuCode(), 128), row.cartonCount(),
|
||||
row.itemQuantity(), text(row.unitsPerCarton(), 30)));
|
||||
}
|
||||
return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80), text(value.inboundCodes(), 300),
|
||||
value.cartonCount(), value.skuCount(), decimal(value.weightKg()), decimal(value.volumeCbm()),
|
||||
barcode(value.barcodeContent()), List.copyOf(rows));
|
||||
return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80),
|
||||
text(value.destinationWarehouse(), 200), value.palletSequence(), value.totalPallets(),
|
||||
barcode(value.barcodeContent()), List.copyOf(inboundRows), List.copyOf(rows));
|
||||
}
|
||||
|
||||
private BigDecimal decimal(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
|
||||
private boolean negative(BigDecimal value) { return value != null && value.signum() < 0; }
|
||||
private String barcode(String value) {
|
||||
String normalized = text(value, 80);
|
||||
if (normalized.codePoints().anyMatch(codePoint -> codePoint < 0x20 || codePoint > 0x7e)) invalid("barcodeContent must contain printable ASCII only");
|
||||
|
||||
@@ -279,7 +279,7 @@ components:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, downloadFileName, documents]
|
||||
properties:
|
||||
templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V1] }
|
||||
templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V2] }
|
||||
pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' }
|
||||
downloadFileName: { type: string, maxLength: 120 }
|
||||
documents:
|
||||
@@ -290,33 +290,43 @@ components:
|
||||
items: { $ref: '#/components/schemas/DropshipPalletA4Document' }
|
||||
DropshipPalletA4Document:
|
||||
type: object
|
||||
required: [palletNo, inboundCodes, cartonCount, skuCount, weightKg, volumeCbm, barcodeContent, skuRows]
|
||||
required: [palletNo, destinationWarehouse, palletSequence, totalPallets, barcodeContent, inboundRows, skuRows]
|
||||
properties:
|
||||
palletNo: { type: string, maxLength: 80 }
|
||||
inboundCodes: { type: string, maxLength: 300 }
|
||||
cartonCount: { type: integer, minimum: 0 }
|
||||
skuCount: { type: integer, minimum: 0 }
|
||||
weightKg: { type: number, minimum: 0 }
|
||||
volumeCbm: { type: number, minimum: 0 }
|
||||
destinationWarehouse: { type: string, maxLength: 200 }
|
||||
palletSequence: { type: integer, minimum: 1 }
|
||||
totalPallets: { type: integer, minimum: 1 }
|
||||
barcodeContent:
|
||||
type: string
|
||||
maxLength: 80
|
||||
pattern: '^[\\x20-\\x7E]+$'
|
||||
description: Code 128 一维码内容,当前固定为稳定托盘码
|
||||
inboundRows:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 10
|
||||
items: { $ref: '#/components/schemas/DropshipPalletInboundRow' }
|
||||
skuRows:
|
||||
type: array
|
||||
minItems: 0
|
||||
maxItems: 34
|
||||
items: { $ref: '#/components/schemas/DropshipPalletSkuRow' }
|
||||
DropshipPalletSkuRow:
|
||||
DropshipPalletInboundRow:
|
||||
type: object
|
||||
required: [inboundCode, skuCode, cargoName, cartonCount, skuQuantity]
|
||||
required: [inboundCode, customerNickname, cartonCount, itemQuantity]
|
||||
properties:
|
||||
inboundCode: { type: string, maxLength: 80 }
|
||||
skuCode: { type: string, maxLength: 128 }
|
||||
cargoName: { type: string, maxLength: 200 }
|
||||
customerNickname: { type: string, maxLength: 80 }
|
||||
cartonCount: { type: integer, minimum: 0 }
|
||||
skuQuantity: { type: integer, minimum: 0 }
|
||||
itemQuantity: { type: integer, minimum: 0 }
|
||||
DropshipPalletSkuRow:
|
||||
type: object
|
||||
required: [skuCode, cartonCount, itemQuantity, unitsPerCarton]
|
||||
properties:
|
||||
skuCode: { type: string, maxLength: 128 }
|
||||
cartonCount: { type: integer, minimum: 0 }
|
||||
itemQuantity: { type: integer, minimum: 0 }
|
||||
unitsPerCarton: { type: string, maxLength: 30 }
|
||||
FreightA4PageSpec:
|
||||
type: object
|
||||
required: [widthMm, heightMm]
|
||||
|
||||
@@ -216,7 +216,7 @@ class InternalPdfControllerTest {
|
||||
.contains("name: X-Internal-Service-Token")
|
||||
.contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:")
|
||||
.contains("BILL_OF_LADING_CONFIRMATION_A4_V1:")
|
||||
.contains("DROPSHIP_PALLET_A4_V1")
|
||||
.contains("DROPSHIP_PALLET_A4_V2")
|
||||
.contains("additionalProperties: false")
|
||||
.contains("minItems: 1")
|
||||
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
|
||||
@@ -268,23 +268,26 @@ class InternalPdfControllerTest {
|
||||
private String validDropshipPalletA4RequestJson() {
|
||||
return """
|
||||
{
|
||||
"templateCode": "DROPSHIP_PALLET_A4_V1",
|
||||
"templateCode": "DROPSHIP_PALLET_A4_V2",
|
||||
"pageSpec": {"widthMm": 210, "heightMm": 297},
|
||||
"downloadFileName": "DP-260911-0034-A4托盘信息单.pdf",
|
||||
"documents": [{
|
||||
"palletNo": "DP-260911-0034",
|
||||
"inboundCodes": "NYEXX2609110249",
|
||||
"cartonCount": 3,
|
||||
"skuCount": 1,
|
||||
"weightKg": 90,
|
||||
"volumeCbm": 0.534,
|
||||
"destinationWarehouse": "NJDF",
|
||||
"palletSequence": 1,
|
||||
"totalPallets": 3,
|
||||
"barcodeContent": "DP-260911-0034",
|
||||
"skuRows": [{
|
||||
"inboundRows": [{
|
||||
"inboundCode": "NYEXX2609110249",
|
||||
"skuCode": "SKU-001",
|
||||
"cargoName": "蓝牙音箱",
|
||||
"customerNickname": "小虫科技",
|
||||
"cartonCount": 3,
|
||||
"skuQuantity": 90
|
||||
"itemQuantity": 90
|
||||
}],
|
||||
"skuRows": [{
|
||||
"skuCode": "SKU-001",
|
||||
"cartonCount": 3,
|
||||
"itemQuantity": 90,
|
||||
"unitsPerCarton": "30"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
+20
-15
@@ -27,11 +27,12 @@ class DropshipPalletA4PdfServiceGenerationTest {
|
||||
@Test
|
||||
void generatesOneA4PageWithPalletBarcodeAndSkuDetails() throws Exception {
|
||||
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
|
||||
"DP-260911-0034", "NYEXX2609110249", 3, 2,
|
||||
new BigDecimal("90"), new BigDecimal("0.534"), "DP-260911-0034",
|
||||
"DP-260911-0034", "NJDF", 1, 3, "DP-260911-0034",
|
||||
List.of(new DropshipPalletA4PdfDtos.InboundRow(
|
||||
"NYEXX2609110249", "小虫科技", 3, 90)),
|
||||
List.of(
|
||||
new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-001", "蓝牙音箱", 2, 40),
|
||||
new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-002", "充电线", 1, 50)));
|
||||
new DropshipPalletA4PdfDtos.SkuRow("SKU-001", 2, 40, "20"),
|
||||
new DropshipPalletA4PdfDtos.SkuRow("SKU-002", 1, 50, "50")));
|
||||
var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
|
||||
DropshipPalletA4PdfDtos.TEMPLATE,
|
||||
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
|
||||
@@ -43,24 +44,28 @@ class DropshipPalletA4PdfServiceGenerationTest {
|
||||
assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f));
|
||||
assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
|
||||
assertThat(new PDFTextStripper().getText(pdf))
|
||||
.contains("DP-260911-0034", "NYEXX2609110249", "SKU 明细", "SKU-001", "SKU-002", "40", "50")
|
||||
.contains("DP-260911-0034", "NJDF", "NYEXX2609110249",
|
||||
"SKU-001", "SKU-002", "40", "50")
|
||||
.containsOnlyOnce("DP-260911-0034")
|
||||
.doesNotContain("状态", "待封托", "已封托");
|
||||
.doesNotContain("状态", "待封托", "已封托", "重量", "体积");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTenInboundCodesAndThirtyTwoSkuRowsOnOneA4Page() throws Exception {
|
||||
List<String> inboundCodes = new ArrayList<>();
|
||||
for (int index = 1; index <= 10; index++) inboundCodes.add("NYEXX260911" + String.format("%04d", 240 + index));
|
||||
List<DropshipPalletA4PdfDtos.InboundRow> inboundRows = new ArrayList<>();
|
||||
for (int index = 1; index <= 10; index++) {
|
||||
inboundRows.add(new DropshipPalletA4PdfDtos.InboundRow(
|
||||
"NYEXX260911" + String.format("%04d", 240 + index), "客户" + index, 2, index * 5));
|
||||
}
|
||||
List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
|
||||
for (int index = 1; index <= 32; index++) {
|
||||
rows.add(new DropshipPalletA4PdfDtos.SkuRow(inboundCodes.get((index - 1) % inboundCodes.size()),
|
||||
"SKU-" + String.format("%03d", index), "测试商品" + index, 2, index * 5));
|
||||
rows.add(new DropshipPalletA4PdfDtos.SkuRow(
|
||||
"SKU-" + String.format("%03d", index), 2, index * 5, "5-10"));
|
||||
}
|
||||
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
|
||||
"DP-260911-0034", String.join("/", inboundCodes), 64, 32,
|
||||
new BigDecimal("860.5"), new BigDecimal("6.728"), "DP-260911-0034", rows);
|
||||
"DP-260911-0034", "NJDF", 1, 1,
|
||||
"DP-260911-0034", inboundRows, rows);
|
||||
var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
|
||||
DropshipPalletA4PdfDtos.TEMPLATE,
|
||||
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
|
||||
@@ -77,11 +82,11 @@ class DropshipPalletA4PdfServiceGenerationTest {
|
||||
void rejectsSkuRowsThatCannotFitOnOneA4Page() {
|
||||
List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
|
||||
for (int index = 0; index < 35; index++) {
|
||||
rows.add(new DropshipPalletA4PdfDtos.SkuRow("INBOUND", "SKU-" + index, "商品", 1, 1));
|
||||
rows.add(new DropshipPalletA4PdfDtos.SkuRow("SKU-" + index, 1, 1, "1"));
|
||||
}
|
||||
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
|
||||
"DP-260911-0034", "INBOUND", 35, 35,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, "DP-260911-0034", rows);
|
||||
"DP-260911-0034", "NJDF", 1, 1, "DP-260911-0034",
|
||||
List.of(new DropshipPalletA4PdfDtos.InboundRow("INBOUND", "客户", 35, 35)), rows);
|
||||
|
||||
assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request(
|
||||
DropshipPalletA4PdfDtos.TEMPLATE,
|
||||
|
||||
+14
-1
@@ -106,7 +106,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|
||||
|---|---|---|---|
|
||||
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、海外排车/出库/派送/异常/回访、财务、通知、统计等模块 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、权限和站内消息数据 | 注册/审核、企业与个人资料、客户隔离、订单、文件、客户侧权限、消息已读与账号安全;未完成注册的同凭据重放须复用账号/客户/成员身份,不同凭据重复用户名须冲突;测量/计费继续使用下列专项入口 |
|
||||
| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、多产品、海外仓、多个私人地址、地址归属失败、渠道失败、重复行、逐行隔离、失败重试、详情归属、幂等重放与冲突;只创建草稿,不提交、不审核 |
|
||||
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||
| Client 入库测量与计费专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 入库测量与计费客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
|
||||
@@ -150,6 +150,14 @@ node scripts/create-pending-review-orders.mjs
|
||||
|
||||
## 本地质量检查(非全量验收)
|
||||
|
||||
合并后海外新增能力的覆盖清单、fixture 依赖和未实现/未覆盖项见
|
||||
`admin-test-env-modules/README.md` 的“合并后的海外增量覆盖”。
|
||||
转运审核、草稿柜及托盘移除、BOL 与后台转派送复用默认注册的
|
||||
`phase4-overseas-client-services`、`phase4-overseas-dispatch`、`phase5-overseas-fulfillment`,
|
||||
不是独立造数脚本;新增离线检查为 `admin-test-env-modules/merged-overseas-coverage-contract.test.mjs`。
|
||||
这些模块真实运行会创建和推进本轮测试业务单据,只有取得对应环境授权后才执行;
|
||||
空驳回原因当前后端未拒绝,必须保留失败,不以离线合同通过声称全量完成。
|
||||
|
||||
Gateway 路由防漏:`node --test scripts/gateway-route-contract.test.mjs`。只读 Maven 模块的 `src/main/java` Controller 与国内/海外源码路由,自动组合类/方法路径并校验目标服务、路由优先顺序和内部路径隔离;不需要凭据、不写数据或报告,失败返回非零,修复后原命令重跑。包含删除船节点路由、错误目标、内部暴露的反向用例。支持当前字面量/类内字符串常量、短格式 Path 配置;复杂映射需同步扩展解析器,不得忽略失败。它不验证 HTTP 方法、权限、外部 Nacos 实际值或运行时可达性。
|
||||
|
||||
`backend-full-regression.ps1` 在 Maven 前执行该合同;根 `Jenkinsfile` 在服务打包调度前独立执行,不受 `SKIP_TESTS` 跳过 Maven 的参数影响。CI 的 `java-builder` 必须提供支持 `node --test` 的 Node.js(建议 22);缺失时门禁失败,不自动安装或静默跳过。直接单服务 Job、手工 Git 提交不会自动触发此脚本,仍须遵守提交前检查规则。
|
||||
@@ -262,3 +270,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 全量入口,本次合并仅做离线合同。
|
||||
|
||||
@@ -37,6 +37,13 @@ export async function run(context) {
|
||||
|
||||
## 可用上下文
|
||||
|
||||
- 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。
|
||||
- `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。
|
||||
- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。
|
||||
- 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。
|
||||
- 排车详情按 `DispatchResponse.Item` 核对托盘与箱数,不读取 DTO 未提供的箱唛字段;`phase5-overseas-fulfillment` 将同一换标排车转为待出库单,通过 `OutboundDetail.pallets.cartonMarks` 验证全部新箱唛、排除原箱唛和重复箱。该测试生成的待出库单及派送单保留为本轮证据,不推进人工退货样本。
|
||||
- 出库前同操作员、同草稿允许重复预排并更新原排车;脚本核对排车身份与业务数量不漂移,不要求重新生成的明细行 ID 不变。其他草稿抢锁仍必须冲突,生成出库单后仍验证托盘编辑被拒绝。
|
||||
|
||||
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
|
||||
|
||||
一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
|
||||
@@ -62,6 +69,33 @@ export async function run(context) {
|
||||
|
||||
## 近期业务模块与静态合同快速索引
|
||||
|
||||
### 合并后的海外增量覆盖
|
||||
|
||||
本节核对 `d4fec829` 首父至 `fb66d920` 的海外后端差异及当前 Admin 调用端。
|
||||
复用已注册默认模块,不新增独立造数入口;定向执行仍由总入口展开本轮客户、国内履约和海外 PDA 前置。
|
||||
离线合同 `merged-overseas-coverage-contract.test.mjs` 锁定默认注册、定向依赖、陈旧 fixture 拒绝及新增断言。
|
||||
离线通过只证明脚本合同,不证明部署环境已通过。
|
||||
|
||||
| 新增功能 | 全量模块 | 本轮 fixture / 依赖 | 正常与失败断言 |
|
||||
|---|---|---|---|
|
||||
| 仓储转运审核 | `phase4-overseas-client-services` 调用内部 `warehouse-transfer-review.mjs` | 本轮资质客户 Token、PDA 完成柜来源订单;另通过客户 API 创建独立新到仓转运对照票 | 平台显式 customerId;客户 Token 无审核权限;缺作用域/非法决定拒绝且状态不漂移;已有库存 APPROVED/PENDING_ASSIGNMENT;新到仓 APPROVE→WAIT_PDA_RECEIVING/WAIT_DELIVERY,REJECT→REJECTED/CANCELLED;同决定重放、反向审核冲突、客户详情回查;空驳回原因应 A0400 并保持待审核 |
|
||||
| 智能预排真实草稿柜 | `phase4-overseas-dispatch` | `phase4-overseas-pda-operations` 后本轮实际收货托盘 | 草稿柜创建、明确 draftContainerId 绑定、结构回查、unlock 恢复候选、删除柜释放锁、重建柜预排;保留同草稿重锁、其他草稿冲突、旧载荷预排冲突与 release 合同 |
|
||||
| 排车托盘移除 | `phase4-overseas-dispatch` | 同一本轮托盘先创建独立移除对照排车,移除后才进入主链预排 | DELETE 明细后箱数/托数/明细归零;重复删除 A0404;正式锁释放后候选恢复。空对照排车保留,不借历史排车 |
|
||||
| 排车编辑、BOL、后台转派送 | `phase5-overseas-fulfillment` | `phase4-overseas-dispatch` 生成的主链排车 | 复用车辆编辑和客户转派送;新增 BOL content-type 与 %PDF- 签名检查;后台同载荷转派送复用同一出库/派送业务号;生成出库后移除托盘 A0409 且数量和身份不漂移;原 PDA 拣货/发车、派送、异常和回访继续执行 |
|
||||
| 退货签收与合箱 | `phase4-overseas-return-receipt` | 本轮退货预报及临时 PDA,不使用历史100单 | 已有分批签收、超收、幂等、数量守恒、库存与计费批次、合箱;不重复编排 |
|
||||
| PDA 收货、换标托盘及采购资金 | `phase4-overseas-pda`、`phase4-overseas-pda-operations`、`phase4-overseas-wallet-and-pallet-cost` | 原本轮收货与钱包前置 | 复用已有收货、换标拆托、出库衔接和采购冻结/结算/释放,不新增旁路写库 fixture |
|
||||
|
||||
明确缺口及待验收(不能计作已覆盖通过):
|
||||
|
||||
- 转运 `Review.remark` 仅有长度限制,`WarehouseTransferService.review` 未校验空驳回原因,也未将审核原因保存到详情或历史。新增空原因负向 HTTP 断言会在当前实现失败,须业务实现专项修复;原 `Detail.remark` 是申请备注,不能冒充审核原因。
|
||||
- 已核对专用 `WAREHOUSE_TRANSFER_APPROVE` Controller 注解;本轮 HTTP 增量覆盖客户 Token 拒绝,但“平台仅列表/仅管理无审核权限”的独立角色矩阵仍缺 fixture。不能用管理员成功替代最小权限验证。
|
||||
- 新到仓转运本轮只验证审核落到 PDA 前置状态;`PdaWarehouseTransferController` 托盘化、客户确认、可选换标与后续预排完整新链尚未编排。不得声称 WAIT_PDA_RECEIVING 就是 PDA 已收货。
|
||||
- 合并新增退货单换标候选及创建换标接口、换标申请编辑能力,尚需各自独立回归。退货出库需另补当前退货箱 fixture 到 RETURN_OUTBOUND 的完整链,不由普通派送退回覆盖。退货模块本轮已将旧 `sourceBatchNo=MERGED` 断言改为实际扣减来源批次去重、真实来源退货单,并补客户箱号分页与标签 SVG 条码;条码实际扫码可读性仍由人工验收。
|
||||
- 智能预排跨客户/跨仓候选混选、不同平台操作员锁隔离与容量边界尚缺独立反向 fixture;本次同操作员不同草稿锁冲突不能替代它们。
|
||||
- UI 专项由人工验收:审核弹窗驳回原因必填、按钮权限、列表状态刷新;排车编辑和移除交互、智能预排拖放/草稿恢复、BOL 排版与打印、请求数量。PDF 签名验证不证明打印样式正确。
|
||||
|
||||
所有真实 HTTP 场景待当前轮另行授权后运行;不读取真实客户 Excel、不造历史样本、不执行 SQL 或部署。
|
||||
|
||||
| 业务链路 | HTTP 模块/共享前置 | 离线静态合同 |
|
||||
|---|---|---|
|
||||
| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` |
|
||||
|
||||
@@ -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/)
|
||||
@@ -123,8 +123,8 @@ 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: [] }
|
||||
const task = { taskId: '81', inboundId: '82', warehouseId: '83', tenantId: '85', generatedPalletCount: 1, palletSetupReady: true, taskStatus: 'CLAIMED',
|
||||
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 = []
|
||||
@@ -138,33 +138,31 @@ async function exerciseSystemPalletScenario(fault = '') {
|
||||
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
|
||||
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.endsWith('/claim')) return response({ ...task, pallets: [] })
|
||||
if (path === '/api/platform/v1/first-leg/pda-dropship/inbound-orders/82/pallets?accountId=85') return response(task.pallets)
|
||||
if (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, 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 +173,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))
|
||||
})
|
||||
|
||||
@@ -112,12 +112,20 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
const totalCartons = Number(measurement?.actualPackageCount)
|
||||
const expectedPalletCount = Number(measurement?.expectedPalletCount)
|
||||
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
|
||||
const generatedPallets = Array.isArray(claimed?.pallets) ? claimed.pallets : []
|
||||
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', claimResponse,
|
||||
generatedPallets.length === expectedPalletCount && generatedPallets.every((item) =>
|
||||
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || '')),
|
||||
if (!claimed?.tenantId || claimed.taskStatus !== 'CLAIMED') return null
|
||||
// Task.pallets contains pallets with actual bound cartons, not unused pre-generated pallets.
|
||||
const palletResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/pda-dropship/inbound-orders/${inboundId}/pallets?accountId=${asId(claimed.tenantId)}`)
|
||||
const palletData = context.apiSuccess(MODULE, '读取本轮入仓单预生成托盘', palletResponse, '', OPTIONS)
|
||||
const generatedPallets = Array.isArray(palletData) ? palletData : []
|
||||
const palletSetupValid = Number(claimed.generatedPalletCount) === expectedPalletCount
|
||||
&& claimed.palletSetupReady === true && generatedPallets.length === expectedPalletCount
|
||||
&& generatedPallets.every((item) =>
|
||||
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || ''))
|
||||
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', palletResponse,
|
||||
palletSetupValid,
|
||||
`expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS)
|
||||
if (!generatedPallets.length) return null
|
||||
if (!palletSetupValid) return null
|
||||
|
||||
let countedCartons = 0
|
||||
const sealedCartonMarks = []
|
||||
@@ -189,8 +197,15 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
||||
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
|
||||
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
||||
&& bound?.pallets?.length === expectedPalletCount,
|
||||
&& bound?.pallets?.length === 1,
|
||||
`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 === bound?.pallets?.length
|
||||
&& 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,14 @@ test('海外空库基线为固定平台管理员提供完整version0授权投影
|
||||
'platform:overseas-warehouse:product:create',
|
||||
'platform:overseas-warehouse:product:edit',
|
||||
])
|
||||
assert.equal(authorities.length, 105)
|
||||
assert.equal(authorities.length, 106)
|
||||
assert.ok(overseasSql.includes(`JSON_LENGTH(authorities_json) <> ${authorities.length}`),
|
||||
'fixed-admin SQL assertion must match the complete authority snapshot')
|
||||
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 +188,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/)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
|
||||
|
||||
const read = name => readFile(new URL(name, import.meta.url), 'utf8')
|
||||
test('merged scenarios remain in default and focused dependency chains', () => {
|
||||
const defaults = parseArguments([]).modules
|
||||
for (const module of ['phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment']) {
|
||||
assert.ok(defaults.includes(module))
|
||||
const focused = parseArguments(['--modules', module]).modules
|
||||
assert.ok(focused.indexOf('merchant-qualification-fixture') >= 0)
|
||||
assert.ok(focused.indexOf('phase4-overseas-pda-operations') < focused.indexOf(module))
|
||||
}
|
||||
})
|
||||
|
||||
test('review helper refuses stale fixture before any request', async () => {
|
||||
const steps = []
|
||||
await verifyWarehouseTransferReview({ marker: 'current', context: { merchantQualificationFixture: { marker: 'old' } },
|
||||
addStep: (...args) => steps.push(args), requestOverseas: () => { throw Error('must not request') } }, 'token', '123', {}, 'T1')
|
||||
assert.equal(steps.length, 1)
|
||||
assert.equal(steps[0][3], false)
|
||||
assert.equal(steps[0][5].deploymentBlocker, true)
|
||||
})
|
||||
|
||||
test('transfer review integration covers both source types and known blank-reason failure', async () => {
|
||||
const source = await read('warehouse-transfer-review.mjs')
|
||||
const caller = await read('phase4-overseas-client-services.mjs')
|
||||
assert.match(caller, /return verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)/)
|
||||
for (const value of ['WAIT_PDA_RECEIVING', 'PENDING_ASSIGNMENT', 'WAIT_DELIVERY', 'CANCELLED',
|
||||
'REJECTED', 'APPROVED', 'customerId=', 'rejected(denied, 403)', 'rejected(missingScope, 400)',
|
||||
'rejected(reverse, 409)', 'rejected(emptyReason, 400)', "sourceLastlegOrderIds: []"])
|
||||
assert.ok(source.includes(value), value)
|
||||
assert.doesNotMatch(source, /fetch\(|mysql|process\.env|\/internal\//)
|
||||
const controller = await read('../../lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java')
|
||||
assert.match(controller, /WAREHOUSE_TRANSFER_APPROVE/)
|
||||
})
|
||||
|
||||
test('draft and removal checks use actual IDs plus readback rather than snapshot-only success', async () => {
|
||||
const source = await read('phase4-overseas-dispatch.mjs')
|
||||
for (const value of ['drafts/containers', 'draftContainerId: containerId', 'container.palletIds',
|
||||
'draft-locks/unlock', 'deletedDrafts', 'removableItem.id', 'removedDetail.data?.packageCount', 'freed.data.some'])
|
||||
assert.ok(source.includes(value), value)
|
||||
})
|
||||
|
||||
test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => {
|
||||
const source = await read('phase5-overseas-fulfillment.mjs')
|
||||
for (const value of ['requestOverseasBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery',
|
||||
'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map'])
|
||||
assert.ok(source.includes(value), value)
|
||||
})
|
||||
|
||||
test('return merge sources are actual consumed batches, not a synthetic MERGED identifier', async () => {
|
||||
const source = await read('phase4-overseas-return-receipt.mjs')
|
||||
assert.doesNotMatch(source, /sourceBatchNo === 'MERGED'/)
|
||||
for (const value of ['consumedBatches', 'new Set(actualBatches)', 'sourceReturnNo === returnNo',
|
||||
'客户箱号管理按实物箱分页', '标签包含SVG条码和当前箱号']) assert.ok(source.includes(value), value)
|
||||
})
|
||||
@@ -2,7 +2,15 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { DOCUMENT_TYPES } from './phase3-container-documents.mjs'
|
||||
import { DOCUMENT_TYPES, applicableDocumentTypes } from './phase3-container-documents.mjs'
|
||||
|
||||
test('rail excludes DO but truck still requires it; missing channel fails closed', () => {
|
||||
for (const code of ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']) {
|
||||
assert.deepEqual(applicableDocumentTypes(code), ['DESTINATION_CLEARANCE'])
|
||||
}
|
||||
assert.deepEqual(applicableDocumentTypes('EXX_TRUCK'), DOCUMENT_TYPES)
|
||||
assert.throws(() => applicableDocumentTypes(null), /权威渠道/)
|
||||
})
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8')
|
||||
|
||||
@@ -6,6 +6,13 @@ export const DOCUMENT_TYPES = Object.freeze([
|
||||
'DESTINATION_CLEARANCE', 'DELIVERY_ORDER',
|
||||
])
|
||||
|
||||
export function applicableDocumentTypes(channelCode) {
|
||||
if (!String(channelCode || '').trim()) throw new Error('货柜缺少权威渠道,不能判断DO适用性')
|
||||
const seaRail = ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']
|
||||
.includes(String(channelCode).trim().toUpperCase())
|
||||
return seaRail ? ['DESTINATION_CLEARANCE'] : [...DOCUMENT_TYPES]
|
||||
}
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
@@ -115,6 +122,13 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${containerId}`)
|
||||
const detail = context.apiSuccess(MODULE, '读取本轮货柜权威渠道', detailResponse, '', OPTIONS)
|
||||
if (id(detail?.container?.id) !== containerId || !detail?.container?.channelCode) {
|
||||
context.addStep(MODULE, '货柜渠道适用性前置', detailResponse, false, '缺少同柜权威渠道', OPTIONS)
|
||||
return
|
||||
}
|
||||
const expectedTypes = applicableDocumentTypes(detail.container.channelCode)
|
||||
const specs = [
|
||||
{
|
||||
type: 'DESTINATION_CLEARANCE',
|
||||
@@ -122,7 +136,7 @@ export async function run(context) {
|
||||
readPath: `${CONTAINERS}/${containerId}/clearance`,
|
||||
completePath: `${CONTAINERS}/${containerId}/clearance/complete`,
|
||||
},
|
||||
...['DELIVERY_ORDER'].map((type) => ({
|
||||
...expectedTypes.filter((type) => type === 'DELIVERY_ORDER').map((type) => ({
|
||||
type,
|
||||
uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`,
|
||||
readPath: `${CONTAINERS}/${containerId}/documents/${type}`,
|
||||
@@ -131,17 +145,28 @@ export async function run(context) {
|
||||
|
||||
const documents = []
|
||||
for (const spec of specs) documents.push(await uploadAndVerify(context, spec))
|
||||
let applicabilityPassed = true
|
||||
if (!expectedTypes.includes('DELIVERY_ORDER')) {
|
||||
const rejected = await upload(context, `${CONTAINERS}/${containerId}/documents/DELIVERY_ORDER/upload`,
|
||||
'DELIVERY_ORDER', 0, `${context.marker} 海铁DO负向验收`, key(context, 'rail-do-rejected'))
|
||||
applicabilityPassed = conflict(rejected) && traced(rejected)
|
||||
&& rejected.body?.message === '海铁渠道不适用DO资料'
|
||||
context.addStep(MODULE, '海铁DO上传按渠道规则拒绝', rejected, applicabilityPassed,
|
||||
`code=${codeOf(rejected)}, requestId=${rejected.requestId}, traceId=${rejected.traceId}`, OPTIONS)
|
||||
}
|
||||
const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`)
|
||||
const overview = context.apiSuccess(MODULE, '回查货柜作业资料聚合', overviewResponse, '', OPTIONS)
|
||||
const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType))
|
||||
const typesValid = expectedTypes.every((type) => storedTypes.has(type))
|
||||
&& (expectedTypes.includes('DELIVERY_ORDER') || !storedTypes.has('DELIVERY_ORDER'))
|
||||
context.assert(MODULE, '同一LOADED柜次聚合返回作业资料且身份未漂移', overviewResponse,
|
||||
id(overview?.container?.id) === containerId && documents.every(Boolean)
|
||||
&& DOCUMENT_TYPES.every((type) => storedTypes.has(type)),
|
||||
&& typesValid && applicabilityPassed,
|
||||
`containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS)
|
||||
if (documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type))) {
|
||||
if (id(overview?.container?.id) === containerId && documents.every(Boolean) && typesValid && applicabilityPassed) {
|
||||
context.createdResources.phase3ContainerDocuments = {
|
||||
marker: context.marker, containerBookingId: containerId, loadingPlanId: planId,
|
||||
warehouseId, finalStatus: 'COMPLETED', documentTypes: [...DOCUMENT_TYPES],
|
||||
warehouseId, finalStatus: 'COMPLETED', documentTypes: expectedTypes,
|
||||
documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])),
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user