diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 32c670b3e..f4b6d8209 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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( diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 9a61bf56d..f2fbcdb7d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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', diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 86599c08d..96039fb8e 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java index 775caebbf..7581947dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java @@ -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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index 45387e73e..7c9c3405f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -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); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java index ed265fbca..8eaf88622 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java @@ -54,11 +54,16 @@ public interface PrintPdfClient { record DropshipPalletA4PdfRequest(String templateCode, PageSpec pageSpec, String downloadFileName, List documents) {} - record DropshipPalletDocument(String palletNo, String inboundCodes, int cartonCount, - int skuCount, BigDecimal weightKg, BigDecimal volumeCbm, - String barcodeContent, List skuRows) {} + record DropshipPalletDocument(String palletNo, String destinationWarehouse, + int palletSequence, int totalPallets, + String barcodeContent, + List inboundRows, + List 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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java index 59b3c9e17..a9569169a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -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> 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 printPallet(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long inboundId, @PathVariable @Positive long palletId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index b1cf08c53..09c04e9ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -38,8 +38,8 @@ public class PlatformSmartLoadingController { public ApiResponse 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 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 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 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 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')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index c7611d355..3b270601c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -240,6 +240,7 @@ public final class LoadingListDtos { String internalRemark, String customerRemark, String warehouseRemark, + String customerServiceRemark, int loadSequence, LocalDateTime frozenAt, Integer itemVersion diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 939eff487..f43568c32 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java index 967b686da..363b29d52 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index aa064081f..92909fb6a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -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")); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index f639861df..6bcaff395 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -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 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 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 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; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 947107039..d8bbabdda 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index d1fd0820c..652823fed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java index f7247eba9..0edfde3e7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java @@ -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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 1bea8b781..a7aee84f2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -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) )) @@ -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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 0d4a61a4f..70c71a249 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -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(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 26b223597..ea840c3d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index e27119f62..b81e9ce57 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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, "费用快照不存在"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java index a94b2e9bd..fa64873bf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java @@ -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, "客户主数据服务暂不可用"); + } + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java index a2cd51904..b959c9aac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java @@ -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 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 customerNicknameCache = new HashMap<>(); List 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 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 skuRows = pallet.items().stream() - .map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(), - item.cargoName(), number(item.cartonCount()), number(item.skuQuantity()))) + List inboundFacts = + palletizationMapper.printInboundRows(pallet.palletId()); + if (inboundFacts == null || inboundFacts.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "托盘未关联入仓单,不能打印"); + } + List 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 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 06296b222..3e5b242a1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -131,16 +131,17 @@ public class LoadingCompletedOutboxService { Map palletManifests) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); - List 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 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 pallets = dropship(fulfillmentType) ? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream() @@ -161,6 +162,34 @@ public class LoadingCompletedOutboxService { destinationType(sorted), first.getLxCode()); } + /** 一件代发装柜按托盘产生分配行,跨区订单明细仍须按来源入仓明细保持唯一。 */ + private List eventItems(List rows, + Map> cartonMarks) { + Map> byDetail = rows.stream().collect(java.util.stream.Collectors.groupingBy( + Row::getSourceInboundDetailId, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List result = new ArrayList<>(); + for (List 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 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 palletRows) { Set palletIds = palletRows.stream().map(PalletRow::getPalletId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 3e8b1e67c..8691f0797 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -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 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 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 members, @@ -903,6 +907,7 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); Set visitedGroups = new HashSet<>(); + Set 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 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) { diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 5d20f8dcf..dba77f523 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -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) diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index afa76cfd2..d2011e866 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -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: diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 21375b117..050f4ac2c 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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: 当前入仓明细尚未分配到本计划的箱数} diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 06cc71d0c..202690ffa 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index a60e5844e..988875e3e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -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("智能推荐允许跨国内仓,但仍只选择柜次渠道") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 49977142c..4a6763a34 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 07d011809..773b5efa3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java index b549689c2..fe1bf0f99 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 10087cbc4..1cf46c76f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 5a024d6e5..36f5a9d45 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -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("装柜完成后的明细永久冻结"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java index 821a4164d..e1d8457e9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java @@ -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"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java index 469493151..2d6c609de 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -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("小虫科技"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 09ca1e1d2..b74f3e744 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -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 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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index c465a7e8f..2c6b38bd5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -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 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 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 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 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(), diff --git a/iam/pom.xml b/iam/pom.xml index fcfc28a60..20770d35f 100644 --- a/iam/pom.xml +++ b/iam/pom.xml @@ -16,6 +16,7 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config org.springframework.cloudspring-cloud-starter-openfeign org.springframework.cloudspring-cloud-starter-loadbalancer + com.belerwebpinyin4j2.5.1 org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime com.h2databaseh2test diff --git a/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java index 578e798a7..5a6fbbfc4 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java @@ -11,7 +11,7 @@ public final class CustomerExportDtos { public record CustomerExportBatch(List 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, diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index ae59df536..ae732b1b1 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -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, diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java index bc3971d9b..b253094bc 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -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; diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java index 403039863..a6f640149 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java @@ -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()); diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 5dd4d22fa..2b2748c2b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -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"); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 5659ec3de..0f0a7809a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -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(), diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 0d7e51849..333c94552 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -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失败', diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 863080a63..3e5897429 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -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) diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index f5c939631..dd8fce05a 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -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} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java index 5fbd42587..adf1dd423 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java @@ -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'") diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 7e3f97d5c..b7b398184 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -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"); diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java index 68a289288..3c4459c92 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java @@ -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("有限责任公司"); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index ce6928eb8..4b8e6074e 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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"); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e215e0bd1..806724be0 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 35c6d97ed..5b4536665 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -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," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 930b74881..c913ef7fa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -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.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.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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 50babb9e9..d20131ad6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -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 remaining = new LinkedHashMap<>(requested); Map emptiedBySku = new LinkedHashMap<>(); - Map> attachedByOrder = new LinkedHashMap<>(); LinkedHashSet consumedBatchNos = new LinkedHashSet<>(); LinkedHashSet consumedReturnNos = new LinkedHashSet<>(); + Map> attachedByOrder = new LinkedHashMap<>(); long total = 0; for (ReturnInventoryCarton source : sources) { List sourceItems = cartonItems.listCarton(source.getId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index d73e33b09..e31d76636 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -87,11 +87,11 @@ public class LastlegOrderService { Page page = orderMapper.selectPage( new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt)); Map> mergeRelations = palletMergeRelations(page.getRecords()); - Map dispatchStatuses = dispatchStatuses(page.getRecords()); Map containerNodes = containerNodes(page.getRecords()); + Map 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 dispatchStatuses(List orders) { + private Map dispatchStatuses(List orders) { if (orders.isEmpty()) return Map.of(); List rows = orderMapper.findDispatchStatuses( orders.stream().map(LastlegOrder::getId).toList()); @@ -591,14 +591,13 @@ public class LastlegOrderService { } private LastlegOrderSummary summary(LastlegOrder o, List relations, - String containerNode, String dispatchStatus) { + String containerNode, String dispatchStatus) { List 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(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 80b5175d1..4c745c739 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -234,6 +234,8 @@ public class LoadingCompletedConsumer { String destinationType = destinationType(payload, source); String customerId = owner.customerId(); String customerName = owner.customerName(); + List items = "ONE_CLICK_DROPSHIP".equals(orderType) + ? canonicalItems(source.items()) : source.items(); List 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> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { Map 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> 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 canonicalItems(List source) { + Map> byDetail = new java.util.LinkedHashMap<>(); + for (var item : source == null ? List.of() : source) { + if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) { + throw new IllegalArgumentException("一件代发来源明细标识缺失"); + } + byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item); + } + List result = new ArrayList<>(); + for (List 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 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 values, + java.util.function.Function getter) { + List present = values.stream().map(getter).filter(Objects::nonNull).toList(); + return present.isEmpty() ? null : present.stream().mapToInt(Integer::intValue).sum(); + } + + private static BigDecimal sumDecimal(List values, + java.util.function.Function getter) { + List 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 sourceMarks) { if (!"OVERSEAS".equals(destinationType)) return; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index 2c0c34867..db1373ca1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -52,6 +52,7 @@ public final class WarehouseTransferRequests { } } + /** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */ public record Review(@NotBlank @Size(max = 32) String decision, @Size(max = 1000) String remark) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index d2490202b..b7f826c1d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -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); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 429a0ddf9..e2e8353e2 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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='退货实物库存箱及箱唛'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index 6e93c451f..605c962ac 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java index 409c5f89b..a0e37a1fa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java @@ -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( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 523c4e2d0..89c063e8a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java index 491b1b4c7..673d0c705 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java @@ -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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 38aad853f..df85a8cc4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -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(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 6f3fbaea6..c09a5086c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -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"); diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index a7d753780..fdddfec73 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -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); } } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 3eab42d11..419b2d182 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -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 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')") diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 60c4b8a48..7c18df6c5 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -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 addInternalNote( + @Operation(summary = "更新订单协作备注", description = "客户备注只读;平台可更新内部备注、仓库备注和客服备注。") + public ApiResponse 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)); } } diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 48fa55833..068440df2 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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) diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index 63de363b4..d07584cd9 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -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 ) {} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 155abaa78..ec298ab30 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -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; diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 40c1b715a..34f5670ad 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -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() + .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); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 8f2a76c8b..fd6c83daf 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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 diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 4aa72693c..5621e0ae9 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -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 selected = orderNos.stream().map(orderByNo::get).filter(Objects::nonNull).toList(); List orderIds = selected.stream().map(OrderEntity::getId).toList(); Map inboundByOrder = summaries(null, orderIds); - Map latestNotes = latestNotes(null, orderIds); Map cargoSummaries = cargoSummaries(null, selected); Map 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 page(Long tenantId, UnifiedOrderQuery query, @@ -89,13 +87,12 @@ public class UnifiedOrderQueryService { Map inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus()) ? optionalSummaries(tenantId, sourceOrderIds) : summaries(tenantId, sourceOrderIds); - Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); Map cargoSummaries = cargoSummaries(tenantId, source.records()); Map childCounts = childCounts(source.records()); Map coLoadByOrder = coLoadGroups( source.records().stream().map(OrderEntity::getId).toList(), includeCoLoadGroups); List 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 orderIds = summaries.stream().map(item -> item.inbound().orderId()).toList(); Map orderById = orders.findByIds(tenantId, orderIds).stream() .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); - Map latestNotes = latestNotes(tenantId, orderIds); Map cargoSummaries = cargoSummaries(tenantId, List.copyOf(orderById.values())); Map childCounts = childCounts(List.copyOf(orderById.values())); Map coLoadByOrder = coLoadGroups( orderIds, includeCoLoadGroups); List 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 latestNotes(Long tenantId, List orderIds) { - return orders.findNotesByOrderIds(tenantId, orderIds).stream().collect(Collectors.toMap( - OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest)); - } - private Map cargoSummaries(Long tenantId, List pageOrders) { if (pageOrders.isEmpty()) return Map.of(); List 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()); } diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 7c3d24e80..47fd3a3bf 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -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 diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index ad2f90bfa..382163c7a 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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: diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index f6a6486bf..99608e18e 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -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); } diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 5d116b78b..36c238443 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -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) diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index bf6ea159b..a92c8b645 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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; diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index f502162aa..d7f5f484e 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -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()); diff --git a/print/AGENTS.md b/print/AGENTS.md index 7f87e386e..f1589d6f3 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -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 汇总,不展示作业状态、重量或体积。新尺寸或布局必须使用新模板编码。 ## 快速索引 diff --git a/print/README.md b/print/README.md index 4f45ee9af..bc2e22e8e 100644 --- a/print/README.md +++ b/print/README.md @@ -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`。 diff --git a/print/output/pdf/dropship-pallet-a4-v2-preview.pdf b/print/output/pdf/dropship-pallet-a4-v2-preview.pdf new file mode 100644 index 000000000..6cc08e92c Binary files /dev/null and b/print/output/pdf/dropship-pallet-a4-v2-preview.pdf differ diff --git a/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java b/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java index 627e8ed85..fefa1eb33 100644 --- a/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java +++ b/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java @@ -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 skuRows) {} + public record Document(String palletNo, String destinationWarehouse, + int palletSequence, int totalPallets, + String barcodeContent, List inboundRows, + List 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) {} } diff --git a/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java b/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java index 5cd8c08f4..1ff6f3f64 100644 --- a/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java +++ b/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java @@ -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 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 rows) throws IOException { + private void skuTable(PDPageContentStream s, PdfUnits u, PDFont f, + List 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 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 wrap(PDFont font, String raw, float size, float maxWidth, - int maxLines) throws IOException { - String remaining = raw == null || raw.isBlank() ? "—" : raw; - List 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(); } } diff --git a/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java b/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java index b95a7d278..139baa94b 100644 --- a/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java +++ b/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java @@ -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 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 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 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"); diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml index d71e12cd5..c9c2adcb6 100644 --- a/print/src/main/resources/openapi/print-internal-v1.yaml +++ b/print/src/main/resources/openapi/print-internal-v1.yaml @@ -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] diff --git a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java index 798c269f4..a0e7e4e40 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java @@ -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" }] }] } diff --git a/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java index 3b7076e93..cf2a311f8 100644 --- a/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java +++ b/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java @@ -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 inboundCodes = new ArrayList<>(); - for (int index = 1; index <= 10; index++) inboundCodes.add("NYEXX260911" + String.format("%04d", 240 + index)); + List 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 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 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, diff --git a/scripts/README.md b/scripts/README.md index e8a95efc8..d984c0701 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 全量入口,本次合并仅做离线合同。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 48f054289..ec940ace6 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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` | diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 53f5a01c9..8ed2a6bf6 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-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)) }) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 55c430736..16263019b 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index b0efc204c..3e714198c 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -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()) +}) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 2b29d2014..bda5d37e9 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -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/) diff --git a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs new file mode 100644 index 000000000..b271ee44a --- /dev/null +++ b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs index 3ec849814..32b0234a4 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs @@ -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') diff --git a/scripts/admin-test-env-modules/phase3-container-documents.mjs b/scripts/admin-test-env-modules/phase3-container-documents.mjs index 93c2ba943..00bc03c9b 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents.mjs @@ -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)])), } } diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 74b0a5af0..adbcf58cb 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -18,7 +18,7 @@ test('order fixture defaults to FBA and preserves explicit OVERSEAS or PRIVATE r const orders = read('orders.mjs') assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/) assert.match(orders, /destinationType: master\.destinationType/) - assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/) + assert.match(orders, /fulfillmentType: dropship \? 'ONE_CLICK_DROPSHIP' : 'STANDARD'/) assert.match(orders, /skuCodes: payload\.cargoes\.map\(\(cargo\) => cargo\.skuNo\)/) assert.match(orders, /skuLines: payload\.cargoes\.map\(\(cargo\) => \(\{/) assert.match(orders, /destinationType,\s*\n\s*\}\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 278a686a6..f0f864410 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -1,3 +1,5 @@ +import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs' + const MODULE = 'phase4-overseas-client-services' const SCENARIO = 'phase4-overseas-client-services-lifecycle' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -196,7 +198,7 @@ async function verifyTransfer(context, token, orders) { && detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0, `status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.transferNo = transferNo - return true + return verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo) } async function verifyDiscard(context, token, markerOrders) { @@ -244,7 +246,7 @@ export async function run(context) { context.createdResources.phase4OverseasClientServices = { marker: context.marker, relabelNo: null, transferNo: null, discardNo: null, finalStatuses: { relabel: null, transfer: null, discard: null }, - cleanup: 'Merchant applications remain in their legal initial review/processing states; no outbound, dispatch, PDA or delivery action is invoked', + cleanup: 'Relabel/discard remain pending; transfers are reviewed with separate incoming approval/rejection fixtures; no outbound, dispatch, PDA or delivery action is invoked', } const token = await loginMerchant(context) if (!token) return @@ -269,7 +271,7 @@ export async function run(context) { const discardDone = await verifyDiscard(context, token, orders) context.createdResources.phase4OverseasClientServices.finalStatuses = { relabel: relabelDone ? 'PENDING_PROCESSING' : null, - transfer: transferDone ? 'PENDING_REVIEW' : null, + transfer: transferDone ? 'APPROVED' : null, discard: discardDone ? 'PENDING_REVIEW' : null, } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index 8c6c5b71d..76995bfb6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' +import { dispatchBusinessSnapshot } from './phase4-overseas-dispatch.mjs' const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url) const source = await readFile(fileURLToPath(moduleUrl), 'utf8') @@ -27,11 +28,11 @@ test('module uses marker-owned completed PDA pallets and never borrows historica assert.doesNotMatch(source, /candidates\[0\]/) }) -test('module proves draft idempotency, lock isolation, stale replay conflict and readback', () => { +test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => { assert.match(source, /相同草稿快照重复保存/) assert.match(source, /同草稿重复锁定托盘幂等成功/) assert.match(source, /其他草稿锁定同一托盘返回A0409/) - assert.match(source, /已转换正式锁的旧草稿载荷再次预排返回A0409/) + assert.match(source, /出库前同草稿重复预排复用原排车且业务快照不漂移/) assert.match(source, /重复释放草稿幂等成功/) assert.match(source, /排车详情按记录自身客户范围加载托盘明细/) assert.match(source, /response\?\.http === 409.*codeOf\(response\) === 'A0409'/s) @@ -58,3 +59,22 @@ test('module prefers marker-owned relabel pallets and proves only new FBA carton assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/) assert.match(source, /relabelNo: relabel\.relabelNo/) }) + + test('relabel settlement follows pre-arrange and does not replace the ordinary dispatch lifecycle', () => { + assert.match(source, /await runRelabelDispatch\(context, relabel\)[\s\S]*if \(relabel.dispatchNo\) await settleRelabelFulfillment\(context, relabel\)/) + const branch = source.slice(source.indexOf('const relabel = context.createdResources'), source.indexOf('const fixture = markerFixture(context)', source.indexOf('const relabel = context.createdResources'))) + assert.doesNotMatch(branch, /\breturn\b/) + assert.match(source, /Object.assign\(relabel, \{/) +}) + +test('dispatch replay snapshot ignores regenerated item ids but detects business drift', () => { + const a = { id: '1', dispatchNo: 'DS-1', status: 'PENDING_DEPARTURE', packageCount: 2, + palletCount: 1, items: [{ id: '10', planPalletId: '20', warehousePalletId: '30', packageCount: 2 }] } + const replay = { ...a, items: [{ ...a.items[0], id: '11' }] } + assert.equal(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(replay)) + for (const changed of [{ ...replay, id: '2' }, { ...replay, packageCount: 4 }, + { ...replay, items: [{ ...a.items[0], warehousePalletId: '31' }] }]) { + assert.notEqual(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(changed)) + } + assert.doesNotMatch(source, /item\?\.cartonMarks/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 9fbdb834f..59e07045c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -1,3 +1,5 @@ +import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs' + const MODULE = 'phase4-overseas-dispatch' const SCENARIO = 'phase4-overseas-dispatch-lifecycle' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -7,6 +9,15 @@ function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } function success(response) { return response?.http === 200 && codeOf(response) === '200' } +export function dispatchBusinessSnapshot(value) { + return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status, + packageCount: value?.packageCount, palletCount: value?.palletCount, + items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId), + warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage, + packageCount: item.packageCount, inboundCode: item.inboundCode })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + }) +} function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` @@ -69,17 +80,16 @@ async function runRelabelDispatch(context, relabel) { `dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`, OPTIONS) if (!dispatchNo) return - Object.assign(context.createdResources.phase4OverseasDispatch, { + Object.assign(relabel, { dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null, relabelNo: relabel.relabelNo, planPalletId: null, }) const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) - const detailMarks = (detail.data?.items || []).flatMap((item) => String(item?.cartonMarks || '') - .split(',').map((mark) => mark.trim()).filter(Boolean)) - context.assert(MODULE, '排车详情冻结全部换标后箱唛且不混入原箱唛', detail.response, - relabel.relabelCartonMarks.every((mark) => detailMarks.includes(mark)) - && relabel.sourceCartonMarks.every((mark) => !detailMarks.includes(mark)), - `frozenMarks=${detailMarks.join(',')}; ${trace(detail.response)}`, OPTIONS) + // DispatchResponse.Item omits cartonMarks. Phase5 verifies the persisted marks + // through OutboundDetail.pallets after transferring this exact dispatch. + context.assert(MODULE, '排车详情保留本轮换标托盘与箱数快照', detail.response, + success(detail.response) && dispatchBusinessSnapshot(detail.data) === dispatchBusinessSnapshot(arranged.data), + trace(detail.response), OPTIONS) } export async function run(context) { @@ -99,7 +109,7 @@ export async function run(context) { const relabel = context.createdResources?.phase4OverseasRelabel if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') { await runRelabelDispatch(context, relabel) - return + if (relabel.dispatchNo) await settleRelabelFulfillment(context, relabel) } const fixture = markerFixture(context) @@ -122,6 +132,30 @@ export async function run(context) { const planId = id(candidate.planId) const planPalletId = id(candidate.planPalletId) + // Exercise removal on a disposable dispatch, then reuse the released current-run pallet. + const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, { + planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`, + }) + const removableNo = id(removable.data?.dispatchNo) + const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId) + if (!success(removable.response) || !removableNo || !removableItem?.id) { + blocker(context, '移除对照排车前置', '本轮托盘未生成排车明细', removable.response) + return + } + const removePath = `${BASE}/${encodeURIComponent(removableNo)}/items/${encodeURIComponent(id(removableItem.id))}` + const removed = await api(context, 'DELETE', '排车移除未拣托盘并释放正式锁', removePath) + const removedDetail = await api(context, 'GET', '回查移除后排车数量', `${BASE}/${encodeURIComponent(removableNo)}`) + context.assert(MODULE, '移除最后托盘后箱数托数明细归零', removedDetail.response, + success(removed.response) && success(removedDetail.response) + && Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0 + && removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS) + const removedAgain = await context.requestOverseas('DELETE', removePath) + context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain, + removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS) + const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) + context.assert(MODULE, '正式锁释放后候选恢复', freed.response, + Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS) + context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64) const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }]) const snapshotBody = { draftToken, planIds: [planId], containersJson } @@ -143,7 +177,28 @@ export async function run(context) { && matchingDrafts[0].containersJson === containersJson, `matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS) - const lockBody = { planPalletId, draftToken } + const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 }) + const containerId = id(disposableContainer.data?.id) + if (!success(disposableContainer.response) || !containerId) return + const boundBody = { planPalletId, draftToken, draftContainerId: containerId } + const bound = await api(context, 'POST', '托盘绑定真实草稿柜', `${BASE}/draft-locks/lock`, boundBody) + if (!success(bound.response)) return + const boundDrafts = await api(context, 'GET', '回查真实草稿柜及托盘绑定', `${BASE}/drafts`) + context.assert(MODULE, '草稿结构回显服务端柜ID和托盘', boundDrafts.response, + boundDrafts.data?.find(draft => draft.draftToken === draftToken)?.containers?.some(container => + id(container.id) === containerId && container.palletIds?.map(id).includes(planPalletId)), trace(boundDrafts.response), OPTIONS) + await api(context, 'POST', '移出草稿托盘解锁', `${BASE}/draft-locks/unlock`, boundBody) + const unlocked = await api(context, 'GET', '解锁后回查候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) + context.assert(MODULE, '草稿解锁恢复候选', unlocked.response, + unlocked.data?.some(item => id(item.planPalletId) === planPalletId), trace(unlocked.response), OPTIONS) + await api(context, 'POST', '重新锁定以验证删除柜释放锁', `${BASE}/draft-locks/lock`, boundBody) + await api(context, 'DELETE', '删除本轮草稿柜并释放绑定托盘', `${BASE}/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`) + const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`) + context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response, + Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS) + const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 }) + if (!success(finalContainer.response) || !finalContainer.data?.id) return + const lockBody = { planPalletId, draftToken, draftContainerId: id(finalContainer.data.id) } const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody) if (!success(locked.response)) return const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody) @@ -186,8 +241,10 @@ export async function run(context) { context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody) - context.addStep(MODULE, '已转换正式锁的旧草稿载荷再次预排返回A0409', duplicateArrange, - conflict(duplicateArrange), trace(duplicateArrange), OPTIONS) + context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange, + success(duplicateArrange) + && dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data), + trace(duplicateArrange), OPTIONS) const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, { page: 1, size: 20, dispatchNo, diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 556e3d9d5..fee612bc2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -4,7 +4,14 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { expandOverseasCartonCandidates, resolveCartonFixtures, run } from './phase4-overseas-pda.mjs' +import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer } from './phase4-overseas-pda.mjs' + +test('one physical container is received once even when matrix roles overlap', () => { + const fba = { containerNo: 'TEST-C1', fulfillmentType: 'STANDARD' } + const overseas = { containerNo: 'TEST-C2' } + assert.deepEqual([null, fba, { ...fba }, overseas].filter(uniqueContainerProducer), [fba, overseas]) + assert.doesNotMatch(source, /dropship \|\| matrix/) +}) const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 0149e0305..6ddf32790 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -5,7 +5,26 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { run } from './phase4-overseas-pda-operations.mjs' +import { run, remainingRelabelItem, selectPhysicalRelabelSources } from './phase4-overseas-pda-operations.mjs' + +test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => { + const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [ + { positionId: '10', palletNo: 'REAL-A', availablePackages: 2 }, + { positionId: '11', palletNo: 'UNBOUND', availablePackages: 1 }] } + const b = { inventoryBalanceId: 'B', inboundOrderId: '2', positions: [ + { positionId: '20', palletNo: 'REAL-B', availablePackages: 1 }] } + const selected = selectPhysicalRelabelSources([b, a, a], 'A') + assert.deepEqual(selected.map(line => line.position.palletNo), ['REAL-A', 'REAL-A', 'REAL-B']) + assert.equal(selectPhysicalRelabelSources([a, a], 'A').length, 2) +}) + +test('exception uses the untouched third carton, independent of response ordering', () => { + const third = { itemId: '3', expectedPackages: 1 } + const detail = { items: [third, { itemId: '2' }, { itemId: '1' }] } + assert.equal(remainingRelabelItem(detail, [1, '2']), third) + assert.equal(remainingRelabelItem(detail, ['1', '2', '3']), null) + assert.equal(remainingRelabelItem(null, []), null) +}) const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8') @@ -56,7 +75,8 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho 'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null', 'newLabel: item.fbaCartonMark || item.newSkuCode', "operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:', - 'P5换标费用冻结后开放PDA作业', 'CUSTOMER_CONFIRM_PENDING', 'confirm-completion', + "deliveryMethod: 'TRUCK'", 'printShippingLabel: false', + 'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛', 'CUSTOMER_CONFIRM_PENDING', 'confirm-completion', 'P5库存流水记录换标拆托原因和正负变动', 'phase4OverseasRelabel']) { assert.ok(source.includes(marker), marker) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 5723b0b36..2855f8c70 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -4,6 +4,7 @@ const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) function id(value) { return value == null ? '' : String(value) } +function longId(value) { return /^\d+$/.test(String(value || '')) && String(value) !== '0' } function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function conflict(response) { @@ -86,51 +87,38 @@ async function expectConflict(context, token, method, name, path, body) { } async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') { - return request(context, token, 'POST', name, + return request(context, token, 'POST', `${name}(${palletCode})`, '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) } -async function createLocation(context, warehouseId, suffix) { - const locationCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-64) - const response = await context.requestOverseas('POST', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { - zoneCode: 'PDAOPS', locationCode, locationType: 'STORAGE', maxLoadKg: 5000, - status: 'IDLE', remark: `${context.marker}-pda-operations-${suffix}`, - }) - const locationId = id(response?.body?.data) - context.addStep(MODULE, `创建${suffix}目标库位`, response, - success(response) && /^\d+$/.test(locationId) && locationId !== '0', - `locationId=${locationId || 'missing'}, locationCode=${locationCode}; ${trace(response)}`, OPTIONS) - return success(response) ? { locationId, locationCode } : null +async function resolvePublicLocation(context, fixture) { + const response = await context.requestOverseas('GET', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`) + const location = records(response?.body?.data).find((item) => item?.locationType === 'VIRTUAL' + && item?.status === 'ACTIVE') + context.addStep(MODULE, '复用当前海外仓公共库位', response, + success(response) && longId(id(location?.id)) && nonBlank(location?.locationCode), + `locationId=${id(location?.id) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(response)}`, OPTIONS) + return success(response) && location + ? { locationId: id(location.id), locationCode: location.locationCode } : null } -export function auxiliaryPalletFbaWarehouseCode(destinationType, fbaWarehouseCode) { - if (destinationType === 'OVERSEAS') return null - if (destinationType === 'FBA' && nonBlank(fbaWarehouseCode)) return fbaWarehouseCode.trim() - return null +export function remainingRelabelItem(detail, usedItemIds) { + const used = new Set(usedItemIds.map(id)) + return (detail?.items || []).find((item) => id(item?.itemId) && !used.has(id(item.itemId))) || null } -async function createEmptyPallet(context, token, fixture, suffix) { - const fbaWarehouseCode = auxiliaryPalletFbaWarehouseCode( - fixture.destinationType, fixture.fbaWarehouseCode) - if (fixture.destinationType === 'FBA' && !fbaWarehouseCode) { - blocker(context, `${suffix}辅助空托盘`, - `当前FBA入仓码 ${fixture.inboundCode || 'missing'} 缺少权威fbaWarehouseCode`) - return null +async function resolveReusableAuxiliaryPallet(context, token, fixture) { + for (const candidate of fixture.auxiliaryPallets || []) { + if (!nonBlank(candidate?.palletCode) || id(candidate?.palletId) === id(fixture.pallet?.palletId)) continue + const resolved = await resolvePallet(context, token, candidate.palletCode, '解析入仓计划已有空托盘') + if (resolved.data && Array.isArray(resolved.data.inventory) && resolved.data.inventory.length === 0) { + context.assert(MODULE, '辅助托盘只复用本轮入仓计划已有空托盘', resolved.response, true, + `palletCode=${resolved.data.palletCode}; ${trace(resolved.response)}`, OPTIONS) + return resolved.data + } } - const location = await createLocation(context, fixture.overseasWarehouseId, `${suffix}-PALLET`) - if (!location) return null - const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120) - const response = await context.requestOverseas('POST', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, { - palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', - locationId: location.locationId, - fbaWarehouseCode, - remark: `${context.marker}-pda-operations-${suffix}`, - }) - context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS) - if (!success(response)) return null - return (await resolvePallet(context, token, palletCode, `解析${suffix}辅助空托盘`)).data + return null } async function verifyStocktake(context, token, fixture) { @@ -201,7 +189,7 @@ async function verifyStocktake(context, token, fixture) { } async function verifyInventoryMove(context, token, fixture) { - const target = await createLocation(context, fixture.overseasWarehouseId, 'MOVE') + const target = await resolvePublicLocation(context, fixture) if (!target) return false let pallet = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P3移库前解析整托')).data if (!pallet || !nonBlank(pallet.locationCode)) { @@ -209,6 +197,14 @@ async function verifyInventoryMove(context, token, fixture) { return false } const originalLocationCode = pallet.locationCode + if (originalLocationCode === target.locationCode) { + const availablePackages = (pallet.inventory || []).reduce((sum, item) => + sum + Number(item?.availablePackages || 0), 0) + context.assert(MODULE, 'P3托盘已在公共库位不重复移库', null, + availablePackages > 0, + `palletCode=${pallet.palletCode}, locationCode=${target.locationCode}, availablePackages=${availablePackages}`, OPTIONS) + return availablePackages > 0 + } const path = '/api/pda/v1/last-leg/inventory-moves' const body = { sourcePalletCode: pallet.palletCode, targetLocationCode: target.locationCode, expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-FORWARD'), @@ -258,23 +254,21 @@ async function verifyPalletOperations(context, token, fixture) { '主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱') return false } - const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP') - const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT') - const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND') - if (!swapTarget || !splitTarget || !unbindTarget) { - blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘') + let auxiliary = await resolveReusableAuxiliaryPallet(context, token, fixture) + if (!auxiliary) { + blocker(context, 'P4辅助实物托盘', '本轮入仓计划没有可复用的已有空托盘') return false } const swapBody = { idempotencyKey: key(context, 'PALLET-SWAP'), source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, - target: { palletCode: swapTarget.palletCode, expectedVersion: Number(swapTarget.palletVersion) }, + target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) }, reason: `${context.marker}-swap` } const swapped = await replayOperation(context, token, 'P4整托换托', `${base}/swap`, swapBody, 'PALLET_SWAP') await expectConflict(context, token, 'POST', 'P4换托同键异载荷A0409', `${base}/swap`, { ...swapBody, reason: `${swapBody.reason}-DIFFERENT` }) - let occupiedSwap = operationPallet(swapped.data, swapTarget.palletCode) - || (await resolvePallet(context, token, swapTarget.palletCode, 'P4换托后解析目标托盘')).data + let occupiedSwap = operationPallet(swapped.data, auxiliary.palletCode) + || (await resolvePallet(context, token, auxiliary.palletCode, 'P4换托后解析目标托盘')).data let emptyMain = operationPallet(swapped.data, main.palletCode) || (await resolvePallet(context, token, main.palletCode, 'P4换托后解析原托盘')).data await expectConflict(context, token, 'POST', 'P4换托旧托盘版本A0409', `${base}/swap`, { @@ -288,19 +282,20 @@ async function verifyPalletOperations(context, token, fixture) { await replayOperation(context, token, 'P4换托恢复主托盘', `${base}/swap`, swapBackBody, 'PALLET_SWAP') main = (await resolvePallet(context, token, main.palletCode, 'P4拆托前刷新主托盘')).data + auxiliary = (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托前刷新已有空托盘')).data const sourceLine = main.inventory.find((line) => Number(line.availablePackages) > 1) || main.inventory.find((line) => Number(line.availablePackages) > 0) const onePackage = 1 const disassembleBody = { idempotencyKey: key(context, 'PALLET-DISASSEMBLE'), source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, - targets: [{ target: { palletCode: splitTarget.palletCode, expectedVersion: Number(splitTarget.palletVersion) }, + targets: [{ target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) }, lines: [{ positionId: id(sourceLine.positionId), packages: onePackage, expectedPositionVersion: Number(sourceLine.positionVersion) }] }], reason: `${context.marker}-disassemble` } const disassembled = await replayOperation(context, token, 'P4拆托一箱到辅助托盘', `${base}/disassemble`, disassembleBody, 'PALLET_DISASSEMBLE') - const splitOccupied = operationPallet(disassembled.data, splitTarget.palletCode) - || (await resolvePallet(context, token, splitTarget.palletCode, 'P4拆托后解析辅助托盘')).data + const splitOccupied = operationPallet(disassembled.data, auxiliary.palletCode) + || (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托后解析辅助托盘')).data const mainAfterSplit = operationPallet(disassembled.data, main.palletCode) || (await resolvePallet(context, token, main.palletCode, 'P4拆托后解析主托盘')).data const splitLine = splitOccupied.inventory.find((line) => Number(line.availablePackages) > 0) @@ -313,18 +308,19 @@ async function verifyPalletOperations(context, token, fixture) { await replayOperation(context, token, 'P4合托恢复主托盘', `${base}/assemble`, assembleBody, 'PALLET_ASSEMBLE') main = (await resolvePallet(context, token, main.palletCode, 'P4解绑前刷新主托盘')).data + auxiliary = (await resolvePallet(context, token, auxiliary.palletCode, 'P4解绑前刷新已有空托盘')).data const unbindSource = main.inventory.find((line) => Number(line.availablePackages) > 1) || main.inventory.find((line) => Number(line.availablePackages) > 0) const unbindPreparedBody = { idempotencyKey: key(context, 'PALLET-UNBIND-PREPARE'), source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, - targets: [{ target: { palletCode: unbindTarget.palletCode, expectedVersion: Number(unbindTarget.palletVersion) }, + targets: [{ target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) }, lines: [{ positionId: id(unbindSource.positionId), packages: 1, expectedPositionVersion: Number(unbindSource.positionVersion) }] }], reason: `${context.marker}-unbind-prepare` } const prepared = await replayOperation(context, token, 'P4拆出一箱作为解绑样本', `${base}/disassemble`, unbindPreparedBody, 'PALLET_DISASSEMBLE') - const boundAuxiliary = operationPallet(prepared.data, unbindTarget.palletCode) - || (await resolvePallet(context, token, unbindTarget.palletCode, 'P4解析解绑样本托盘')).data + const boundAuxiliary = operationPallet(prepared.data, auxiliary.palletCode) + || (await resolvePallet(context, token, auxiliary.palletCode, 'P4解析解绑样本托盘')).data const unbindBody = { idempotencyKey: key(context, 'PALLET-UNBIND'), source: { palletCode: boundAuxiliary.palletCode, expectedVersion: Number(boundAuxiliary.palletVersion) }, reason: `${context.marker}-unbind` } @@ -337,6 +333,27 @@ async function verifyPalletOperations(context, token, fixture) { return success(unbound.response) } +export function selectPhysicalRelabelSources(candidates, preferredBalanceId) { + const ordered = [...candidates].sort((a, b) => + Number(id(b.inventoryBalanceId) === id(preferredBalanceId)) - Number(id(a.inventoryBalanceId) === id(preferredBalanceId))) + const sourceLines = [] + const seen = new Set() + for (const source of ordered) { + if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue + for (const position of source.positions || []) { + const positionId = id(position?.positionId) + if (!positionId || seen.has(positionId) || !nonBlank(position?.palletNo) + || position.palletNo.toUpperCase() === 'UNBOUND') continue + seen.add(positionId) + const selected = Math.min(3 - sourceLines.length, Number(position.availablePackages)) + if (!Number.isInteger(selected) || selected <= 0) continue + for (let index = 0; index < selected; index++) sourceLines.push({ source, position, packageCount: 1 }) + if (sourceLines.length === 3) return sourceLines + } + } + return sourceLines +} + async function verifyRelabel(context, token, merchantToken, fixture) { const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId) const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo @@ -347,33 +364,27 @@ async function verifyRelabel(context, token, merchantToken, fixture) { return false } if (!await fundRelabelWallet(context, customerId)) return false - const inventoryCandidatesResponse = await context.requestOverseas('GET', query( - '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { - operationWarehouseId: fixture.overseasWarehouseId, inboundCode: fixture.inboundCode, - }), undefined, {}, merchantToken) - const inventoryCandidates = success(inventoryCandidatesResponse) - && Array.isArray(inventoryCandidatesResponse.body?.data) ? inventoryCandidatesResponse.body.data : [] - const orderedCandidates = [...inventoryCandidates].sort((left, right) => { - const leftPreferred = id(left?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0 - const rightPreferred = id(right?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0 - return rightPreferred - leftPreferred - }) - const sourceLines = [] - let requiredPackages = 3 - for (const source of orderedCandidates) { - if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue - for (const position of source?.positions || []) { - const available = Number(position?.availablePackages) - if (!id(position?.positionId) || available <= 0) continue - const selected = Math.min(requiredPackages, available) - for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) { - sourceLines.push({ source, position, packageCount: 1 }) - } - requiredPackages -= selected - if (requiredPackages === 0) break - } - if (requiredPackages === 0) break + // P4 intentionally leaves one carton UNBOUND. P5 requires physical source pallets, + // and may combine only the current completed container's same-destination inbounds. + const inboundCodes = [...new Set((context.context.phase4OverseasPdaOperations?.completedFixtures || []) + .filter((item) => item.containerNo === fixture.containerNo && item.destinationType === fixture.destinationType + && item.fbaWarehouseCode === fixture.fbaWarehouseCode) + .map((item) => item.inboundCode).filter(nonBlank))] + const inventoryCandidates = [] + let inventoryCandidatesResponse = null + for (const inboundCode of inboundCodes) { + inventoryCandidatesResponse = await context.requestOverseas('GET', query( + '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { + operationWarehouseId: fixture.overseasWarehouseId, inboundCode, + }), undefined, {}, merchantToken) + context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse, + success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS) + if (!success(inventoryCandidatesResponse)) return false + inventoryCandidates.push(...records(inventoryCandidatesResponse.body?.data).filter((item) => + item.inboundCode === inboundCode && id(item.overseasWarehouseId) === id(fixture.overseasWarehouseId))) } + const sourceLines = selectPhysicalRelabelSources(inventoryCandidates, inventory.balanceId) + const requiredPackages = 3 - sourceLines.length context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse, requiredPackages === 0, `sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS) @@ -382,6 +393,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { return false } const relabelBase = '/api/platform/v1/last-leg/relabel-applications' + const merchantRelabelBase = '/api/merchant/v1/last-leg/relabel-applications' const pdaBase = '/api/pda/v1/last-leg/relabel-tasks' const markerSuffix = String(context.marker).slice(-8) const createBody = { @@ -460,8 +472,13 @@ async function verifyRelabel(context, token, merchantToken, fixture) { await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath, { ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') }) detail = scanned.data + if (!detail) return false const skipItem = Array.isArray(detail?.items) ? detail.items.find((candidate) => id(candidate?.itemId) && id(candidate.itemId) !== id(item.itemId)) : null + if (!skipItem) { + blocker(context, 'P5跳过换标明细', '缺少独立的第二箱换标任务', scanned.response) + return false + } const skipBody = { itemId: id(skipItem?.itemId), oldLabel: skipItem?.cartonMark, reason: `${context.marker}-skip`, expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-SKIP') } @@ -470,7 +487,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) { context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response, Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS) detail = skipped.data - const exceptionItem = item + if (!detail) return false + const exceptionItem = remainingRelabelItem(detail, [item.itemId, skipItem.itemId]) + if (!exceptionItem) { + blocker(context, 'P5异常换标明细', '缺少未被正常换标或跳过的第三箱任务', skipped.response) + return false + } const exceptionBody = { itemId: id(exceptionItem?.itemId), oldLabel: null, exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-EXCEPTION') } @@ -481,6 +503,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response, Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS) detail = exception.data + if (!detail) return false const photo = await context.requestOverseasMultipart('POST', `${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, { @@ -524,7 +547,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'), remark: `${context.marker}-customer-confirmed` } const confirmedResponse = await context.requestOverseas('POST', - `${relabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken) + `${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken) const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse, confirmed?.application?.status === 'READY_FOR_OUTBOUND' @@ -644,7 +667,25 @@ async function verifyRelabel(context, token, merchantToken, fixture) { relabelCartonMarks: movedFacts.map((fact) => fact.fbaCartonMark).filter(nonBlank), sourceCartonMarks: movedFacts.map((fact) => fact.cartonMark), status: 'READY_FOR_OUTBOUND', } - return true + return success(approved.response) +} + +export async function settleRelabelFulfillment(context, relabel) { + const relabelNo = relabel.relabelNo + const relabelBase = '/api/platform/v1/last-leg/relabel-applications' + const terminal = await context.requestOverseas('PUT', + `${relabelBase}/${encodeURIComponent(relabelNo)}/status`, { + status: 'COMPLETED', remark: `${context.marker}-fulfillment-complete`, + }) + context.addStep(MODULE, 'P5待出库换标单完成履约', terminal, + success(terminal) && terminal.body?.data?.application?.status === 'COMPLETED', trace(terminal), OPTIONS) + const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), + (response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED') + context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result, + settled.result?.body?.data?.application?.walletStatus === 'SETTLED', + `pollRequests=${settled.attempts}, walletStatus=${settled.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(settled.result)}`, OPTIONS) + relabel.status = terminal.body?.data?.application?.status || relabel.status + return success(terminal) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED' } export async function run(context) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index b6268b4af..3f23972d0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -10,6 +10,10 @@ const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC function id(value) { return value == null ? '' : String(value) } function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } +export function uniqueContainerProducer(item, index, all) { + return nonBlank(item?.containerNo) + && all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index +} function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response) { return String(response?.body?.code ?? '') } function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) } @@ -897,9 +901,9 @@ export async function run(context) { const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') // The sealed dropship pallet proves the indivisible handover. A separate standard FBA // fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks. - const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], standardFba, + const producers = [dropship, standardFba, matrix?.byDestinationType?.OVERSEAS?.[0]] - .filter((item) => nonBlank(item?.containerNo)) + .filter(uniqueContainerProducer) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers if (allProducers.length) await awaitProducerConsumption(context, allProducers) if (producers.length && !(await convertReceivingContainers(context, producers))) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 3507217bf..a975613e9 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -290,10 +290,33 @@ export async function run(context) { if (success(merged.response)) { context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response, nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2 - && merged.data?.label?.sourceBatchNo === 'MERGED' + && nonBlank(merged.data?.label?.sourceBatchNo) && merged.data?.label?.sourceBatchNo !== 'MERGED' + && merged.data?.label?.sourceReturnNo === returnNo && Number(merged.data?.label?.items?.length) === 2 && nonBlank(merged.data?.label?.printableHtml), `cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS) + const consumedBatches = [] + for (const [mark, batch, before] of [[cartonA, firstBatchNo, 2], [cartonB, secondBatchNo, 3]]) { + const source = await overseas(context, merchantToken, 'GET', '合箱后回查来源箱实际扣减', + `${RETURN_INVENTORY}/cartons/${encodeURIComponent(mark)}`) + if (success(source.response) && Number(source.data?.totalQuantity) < before) consumedBatches.push(batch) + } + const actualBatches = String(merged.data?.label?.sourceBatchNo || '').split(',') + context.assert(MODULE, '合箱来源批次精确等于实际消耗来源且去重', merged.response, + consumedBatches.length > 0 && new Set(actualBatches).size === actualBatches.length + && JSON.stringify([...consumedBatches].sort()) === JSON.stringify([...actualBatches].sort()), + `expected=${consumedBatches.join(',')}, actual=${actualBatches.join(',')}`, OPTIONS) + const cartonPage = await overseas(context, merchantToken, 'GET', '客户箱号管理按实物箱分页', + query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 20, returnNo })) + context.assert(MODULE, '箱号分页包含本轮合箱且来源退货单保持真实业务号', cartonPage.response, + records(cartonPage.data).some(row => row.cartonMark === merged.data.cartonMark + && row.sourceReturnNo === returnNo && Number(row.totalQuantity) === 2), trace(cartonPage.response), OPTIONS) + const label = await overseas(context, merchantToken, 'GET', '客户读取本轮合箱条码标签', + `${RETURN_INVENTORY}/cartons/${encodeURIComponent(merged.data.cartonMark)}/label`) + context.assert(MODULE, '标签包含SVG条码和当前箱号', label.response, + label.data?.cartonMark === merged.data.cartonMark + && / { + const relabel = { relabelCartonMarks: ['NEW-1', 'NEW-2'], sourceCartonMarks: ['OLD-1', 'OLD-2'] } + const detail = { outbound: { packageCount: 2 }, pallets: [{ cartonMarks: 'NEW-1,NEW-2' }] } + assert.equal(relabelOutboundMarksMatch(detail, relabel), true) + for (const cartonMarks of ['', 'NEW-1', 'NEW-1,NEW-1', 'NEW-1,OLD-2', 'NEW-1,NEW-2,OTHER']) { + assert.equal(relabelOutboundMarksMatch({ ...detail, pallets: [{ cartonMarks }] }, relabel), false) + } + assert.equal(relabelOutboundMarksMatch({ ...detail, outbound: { packageCount: 3 } }, relabel), false) + assert.match(source, /detail\.data\?\.outbound\?\.dispatchNo === relabel\.dispatchNo/) + assert.match(source, /relabel\?\.marker !== context\.marker/) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index fd7cbc4d1..4f03048f4 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -48,6 +48,35 @@ async function merchant(context, token, method, name, path, body = undefined) { return { response, data } } +export function relabelOutboundMarksMatch(detail, relabel) { + const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') + .split(',').map(mark => mark.trim()).filter(Boolean)) + const expected = relabel?.relabelCartonMarks || [] + return expected.length > 0 && marks.length === expected.length + && new Set(marks).size === marks.length && expected.every(mark => marks.includes(mark)) + && (relabel.sourceCartonMarks || []).every(mark => !marks.includes(mark)) + && Number(detail?.outbound?.packageCount) === expected.length +} + +async function verifyRelabelDispatchOutbound(context, relabel, transferBody) { + if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) { + blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据') + return + } + const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照', + `${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`, + { ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') }) + const outboundNo = transferred.data?.outboundNo + if (!success(transferred.response) || !nonBlank(outboundNo)) return + const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛', + query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 })) + context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response, + success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo + && detail.data?.outbound?.dispatchNo === relabel.dispatchNo + && relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS) + Object.assign(relabel, { outboundNo, deliveryNo: transferred.data?.deliveryNo }) +} + async function loginMerchant(context, customerId) { const merchantFixture = context.context?.merchantQualificationFixture const credentials = nonBlank(merchantFixture?.username) && nonBlank(merchantFixture?.password) @@ -172,6 +201,11 @@ export async function run(context) { trace(updated.response), OPTIONS) if (!success(updated.response)) return + const bol = await context.requestOverseasBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) + context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol, + bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') + && new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS) + const transferBody = { idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64), @@ -182,6 +216,7 @@ export async function run(context) { fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }], } + await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody) const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) const outboundNo = id(transferred.data?.outboundNo) @@ -192,6 +227,21 @@ export async function run(context) { `outboundNo=${outboundNo || 'missing'}, deliveryNo=${deliveryNo || 'missing'}; ${trace(transferred.response)}`, OPTIONS) if (!success(transferred.response) || !outboundNo || !deliveryNo) return Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo }) + const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单', + `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody) + context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response, + adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo + && id(adminReplay.data?.deliveryNo) === deliveryNo, trace(adminReplay.response), OPTIONS) + const lockedItem = updated.data?.items?.[0] + if (lockedItem?.id) { + const deniedRemove = await context.requestOverseas('DELETE', + `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) + context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS) + const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response, + Number(stable.data?.packageCount) === Number(updated.data?.packageCount) + && JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS) + } else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID') const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs new file mode 100644 index 000000000..81f734281 --- /dev/null +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -0,0 +1,83 @@ +const PLATFORM = '/api/platform/v1/last-leg/warehouse-transfers' +const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers' +const MODULE = 'phase4-overseas-client-services' +const OPTIONS = { scenario: 'warehouse-transfer-review', deploymentBlocker: true } +const ok = r => r?.http === 200 && String(r.body?.code) === '200' +const rejected = (r, status) => r?.http === status || (r?.http === 200 && String(r.body?.code) === `A0${status}`) +const evidence = r => `HTTP=${r?.http}, code=${r?.body?.code}, requestId=${r?.requestId}, traceId=${r?.traceId}` + +// Called by the registered client-services module, never a standalone fixture runner. +export async function verifyWarehouseTransferReview(context, token, customerId, sourceBody, transferNo) { + if (!customerId || String(customerId) === '0' + || context.context?.merchantQualificationFixture?.marker !== context.marker) { + context.addStep(MODULE, '转运审核缺少本轮客户作用域', null, false, 'No historical fallback', OPTIONS) + return false + } + const scoped = no => `${PLATFORM}/${encodeURIComponent(no)}?customerId=${encodeURIComponent(customerId)}` + const reviewPath = no => `${PLATFORM}/${encodeURIComponent(no)}/review?customerId=${encodeURIComponent(customerId)}` + async function call(method, path, body, merchantToken) { + return context.requestOverseas(method, path, body, {}, merchantToken) + } + const initial = await call('GET', scoped(transferNo)) + context.addStep(MODULE, '平台转运详情按客户读取待审核单', initial, + ok(initial) && initial.body.data?.status === 'PENDING_REVIEW', evidence(initial), OPTIONS) + if (!ok(initial)) return false + const denied = await call('POST', reviewPath(transferNo), { decision: 'APPROVE' }, token) + context.addStep(MODULE, '客户Token没有平台专用转运审核权限', denied, rejected(denied, 403), evidence(denied), OPTIONS) + const missingScope = await call('POST', `${PLATFORM}/${encodeURIComponent(transferNo)}/review`, { decision: 'APPROVE' }) + context.addStep(MODULE, '平台转运审核缺客户作用域拒绝', missingScope, rejected(missingScope, 400), evidence(missingScope), OPTIONS) + const invalid = await call('POST', reviewPath(transferNo), { decision: 'INVALID' }) + context.addStep(MODULE, '非法审核决定拒绝', invalid, rejected(invalid, 400), evidence(invalid), OPTIONS) + const unchanged = await call('GET', scoped(transferNo)) + context.addStep(MODULE, '审核负向后状态和分配零漂移', unchanged, + ok(unchanged) && unchanged.body.data?.status === 'PENDING_REVIEW' + && JSON.stringify(unchanged.body.data?.allocations) === JSON.stringify(initial.body.data?.allocations), evidence(unchanged), OPTIONS) + + // Existing-stock approval must not be confused with inbound-transit PDA receipt. + const approved = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker }) + context.addStep(MODULE, '已有库存转运通过为APPROVED且主单待分配', approved, + ok(approved) && approved.body.data?.status === 'APPROVED' + && approved.body.data?.orderStatus === 'PENDING_ASSIGNMENT', evidence(approved), OPTIONS) + if (!ok(approved)) return false + const replay = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker }) + context.addStep(MODULE, '同决定重放不推进状态', replay, + ok(replay) && replay.body.data?.status === 'APPROVED' && replay.body.data?.transferNo === transferNo, evidence(replay), OPTIONS) + const reverse = await call('POST', reviewPath(transferNo), { decision: 'REJECT', remark: '反向审核应拒绝' }) + context.addStep(MODULE, '已通过转运不能反向驳回', reverse, rejected(reverse, 409), evidence(reverse), OPTIONS) + const readback = await call('GET', `${MERCHANT}/${encodeURIComponent(transferNo)}`, undefined, token) + context.addStep(MODULE, '客户回查审核通过且未产生出库', readback, + ok(readback) && readback.body.data?.status === 'APPROVED' && readback.body.data?.actualShipAt == null + && Array.isArray(readback.body.data?.allocations) && readback.body.data.allocations.length === 0, evidence(readback), OPTIONS) + + for (const decision of ['APPROVE', 'REJECT']) { + const body = { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [], + idempotencyKey: `${context.marker}-TRANSIT-${decision}`.slice(0, 255) } + const created = await call('POST', MERCHANT, body, token) + const no = created.body?.data?.transferNo + context.addStep(MODULE, `创建本轮新到仓转运${decision}对照`, created, + ok(created) && Boolean(no) && created.body.data?.status === 'PENDING_REVIEW', evidence(created), OPTIONS) + if (!ok(created) || !no) return false + const result = await call('POST', reviewPath(no), { decision, remark: `${context.marker}-审核原因` }) + const expected = decision === 'APPROVE' ? 'WAIT_PDA_RECEIVING' : 'REJECTED' + const expectedOrder = decision === 'APPROVE' ? 'WAIT_DELIVERY' : 'CANCELLED' + const detail = await call('GET', `${MERCHANT}/${encodeURIComponent(no)}`, undefined, token) + context.addStep(MODULE, `新到仓转运${decision}回查${expected}`, detail, + ok(result) && ok(detail) && detail.body.data?.status === expected + && detail.body.data?.orderType === 'WAREHOUSE_TRANSFER' + && detail.body.data?.orderStatus === expectedOrder + && (detail.body.data?.pallets || []).length === 0, evidence(detail), OPTIONS) + context.createdResources.phase4OverseasClientServices[`transit${decision}`] = { transferNo: no, status: detail.body?.data?.status } + } + const reasonFixture = await call('POST', MERCHANT, { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [], + idempotencyKey: `${context.marker}-TRANSIT-REASON`.slice(0, 255) }, token) + const reasonNo = reasonFixture.body?.data?.transferNo + context.addStep(MODULE, '创建空驳回原因独立反向票', reasonFixture, ok(reasonFixture) && Boolean(reasonNo), evidence(reasonFixture), OPTIONS) + if (!ok(reasonFixture) || !reasonNo) return false + const emptyReason = await call('POST', reviewPath(reasonNo), { decision: 'REJECT', remark: '' }) + context.addStep(MODULE, '空驳回原因应拒绝', emptyReason, rejected(emptyReason, 400), evidence(emptyReason), OPTIONS) + const reasonReadback = await call('GET', scoped(reasonNo)) + context.addStep(MODULE, '空原因拒绝后仍待审核', reasonReadback, + ok(reasonReadback) && reasonReadback.body.data?.status === 'PENDING_REVIEW', evidence(reasonReadback), OPTIONS) + context.createdResources.phase4OverseasClientServices.reasonValidationTransferNo = reasonNo + return true +} diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs new file mode 100644 index 000000000..576890d63 --- /dev/null +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('client registration distinguishes incomplete-registration replay from credential conflicts', () => { + assert.match(source, /same credentials replay the incomplete registration/) + assert.match(source, /\['accountId', 'tenantId', 'membershipId'\]/) + assert.match(source, /String\(json\.data\[field\]\), String\(registration\.json\.data\[field\]\)/) + assert.match(source, /duplicate username with different credentials is rejected/) + assert.match(source, /body: \{ \.\.\.registrationBody, password: `Different/) +}) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 380fe7493..ad6943a31 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -142,8 +142,20 @@ async function registrationModule() { report.resources.tenantId = registration.json.data.tenantId report.resources.membershipId = registration.json.data.membershipId - await apiStep('registration', 'duplicate username is rejected', { + await apiStep('registration', 'same credentials replay the incomplete registration without creating another customer', { method: 'POST', path: '/api/auth/register', body: registrationBody, + }, ({ json }) => { + requireSuccess(json) + for (const field of ['accountId', 'tenantId', 'membershipId']) { + assertPresent(json.data?.[field], field) + assertEqual(String(json.data[field]), String(registration.json.data[field]), `replayed ${field}`) + } + assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'replayed tenantStatus') + }) + + await apiStep('registration', 'duplicate username with different credentials is rejected', { + method: 'POST', path: '/api/auth/register', + body: { ...registrationBody, password: `Different${randomUUID()}9!` }, }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0409', 'business code') diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index 23a42d3ea..9811044e9 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 15 }, "openapi": { - "documents": 46, + "documents": 48, "coveredServices": 10, "codeFirstServices": 5 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index caddca9b8..a8b56435d 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,7 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) - assert.equal(summary.openapi.documents, 46) + assert.equal(summary.openapi.documents, 48) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml')) assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml')) assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml')) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 0f7792b31..309907610 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,8 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java', + 'lastleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '根据已授权排车快照生成 BOL PDF,内部令牌保护,无业务状态写入'), feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', 'auth-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 读取授权快照,不写 IAM'), feign('auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 1eb2db488..c2613a429 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -34,6 +34,7 @@ const expectedOnlineEdges = [ 'iam-service->auth-service', 'iam-service->warehouse-service', 'lastleg-service->file-service', + 'lastleg-service->print-service', 'lastleg-service->warehouse-overseas-service', 'lastleg-service->warehouse-service', 'order-service->file-service', @@ -99,11 +100,11 @@ async function discoverFeignEdges() { return clients.sort((left, right) => left.path.localeCompare(right.path)) } -test('all 57 Feign clients have reviewed context, access and idempotency metadata', async () => { +test('all 58 Feign clients have reviewed context, access and idempotency metadata', async () => { const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 57) + assert.equal(reviewed.length, 58) assert.deepEqual(await discoverFeignEdges(), reviewed) })