diff --git a/deploy/sql/README.md b/deploy/sql/README.md
index f5855fbff..60381210e 100644
--- a/deploy/sql/README.md
+++ b/deploy/sql/README.md
@@ -40,8 +40,8 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
-| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
-| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 202 |
+| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
+| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 208 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index 41a092247..287d92fa7 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -2215,7 +2215,7 @@ CREATE TABLE ll_relabel_application (
label_deadline datetime(3) NOT NULL COMMENT '换标截止时间',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名',
contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话',
- delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填',
+ delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK平台卡车、EXPRESS快递、PICKUP自提、AMAZON_PICKUP亚马逊自提、CUSTOMER_PICKUP客户自提;仅平台卡车进入预排车',
print_shipping_label tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要打印面单',
shipping_label_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件ID',
shipping_label_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件名',
@@ -3206,6 +3206,18 @@ CREATE TABLE ll_wallet_event_inbox (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区客户资金结果幂等收件箱';
CREATE TABLE ll_warehouse_transfer_carton (
+ length_cm DECIMAL(12,2) NULL COMMENT '箱子实测长度厘米',
+ width_cm DECIMAL(12,2) NULL COMMENT '箱子实测宽度厘米',
+ height_cm DECIMAL(12,2) NULL COMMENT '箱子实测高度厘米',
+ gross_weight_kg DECIMAL(12,2) NULL COMMENT '箱子实测重量千克',
+ counted_quantity BIGINT NULL COMMENT '箱内SKU实点件数',
+ count_idempotency_key VARCHAR(255) NULL COMMENT '箱内清点幂等键',
+ count_request_hash VARCHAR(64) NULL COMMENT '箱内清点请求摘要',
+ counted_by VARCHAR(128) NULL COMMENT '箱内清点操作人',
+ counted_at DATETIME(3) NULL COMMENT '箱内清点完成时间',
+ discrepancy_reason VARCHAR(1000) NULL COMMENT '实收与预报箱数差异原因',
+ transfer_pallet_id BIGINT NULL COMMENT '签收箱所属转运托盘事实ID',
+ sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细',
id bigint NOT NULL COMMENT '预报箱主键',
transfer_order_id bigint NOT NULL COMMENT '转运单ID',
transfer_item_id bigint NOT NULL COMMENT '转运申报明细ID',
@@ -3249,6 +3261,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录';
CREATE TABLE ll_warehouse_transfer_item (
+ declared_quantity BIGINT NULL COMMENT 'SKU申报件数,非预计或实收箱数',
id bigint NOT NULL COMMENT '主键ID',
transfer_order_id bigint NOT NULL COMMENT '转仓单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
@@ -4268,7 +4281,7 @@ CREATE TABLE ll_selfop_sku_profile (
-CREATE TABLE IF NOT EXISTS ll_appointment_impact (
+CREATE TABLE ll_appointment_impact (
container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示',
impact_no VARCHAR(40) NOT NULL COMMENT '船期核对事项业务号',
baseline_json LONGTEXT NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径',
@@ -4286,7 +4299,7 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact (
CONSTRAINT chk_appointment_impact_status CHECK (status IN ('MONITORING','OPEN','RESOLVED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输';
-CREATE TABLE IF NOT EXISTS ll_appointment_impact_history (
+CREATE TABLE ll_appointment_impact_history (
id BIGINT NOT NULL COMMENT '历史记录标识',
container_booking_id VARCHAR(64) NOT NULL COMMENT '关联柜次标识',
version INT NOT NULL COMMENT '记录版本',
@@ -4335,47 +4348,29 @@ CREATE TABLE ll_return_sku_conversion (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录';
-- PDA cargo discard photo evidence
-CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
- id BIGINT NOT NULL,
- customer_id VARCHAR(64) NOT NULL,
- application_id BIGINT NOT NULL,
- pallet_no VARCHAR(128) NOT NULL,
- file_id BIGINT NOT NULL,
- file_name VARCHAR(255) NOT NULL,
- content_type VARCHAR(128) NOT NULL,
- size_bytes BIGINT NOT NULL,
- operator_id VARCHAR(64) NOT NULL,
- created_at DATETIME(6) NOT NULL,
- submitted_at DATETIME(6) NULL,
+CREATE TABLE ll_cargo_discard_photo (
+ id BIGINT NOT NULL COMMENT '照片记录标识',
+ customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
+ application_id BIGINT NOT NULL COMMENT '废弃申请标识',
+ pallet_no VARCHAR(128) NOT NULL COMMENT '托盘业务码',
+ file_id BIGINT NOT NULL COMMENT '照片文件标识',
+ file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
+ content_type VARCHAR(128) NOT NULL COMMENT '文件媒体类型',
+ size_bytes BIGINT NOT NULL COMMENT '文件字节数',
+ operator_id VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
+ created_at DATETIME(6) NOT NULL COMMENT '照片创建时间',
+ submitted_at DATETIME(6) NULL COMMENT '照片提交时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA货物废弃照片凭证';
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
-SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
- 'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
-PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
- 'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
-PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
- 'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
-PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
-SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
- 'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
-PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
-ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
-SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
- 'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
-PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键',
@@ -4414,39 +4409,15 @@ CREATE TABLE ll_selfop_product (
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
USE baiyue_lastleg;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
-PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
-PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
USE baiyue_lastleg;
-- 历史库存基线;执行前必须取得指定环境的建表授权。
-CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
+CREATE TABLE ll_selfop_inventory_flow (
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
@@ -4482,7 +4453,7 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
-CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
+CREATE TABLE ll_selfop_inventory_day_version (
id BIGINT NOT NULL COMMENT '不可变历史日版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
@@ -4512,7 +4483,7 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
-CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
+CREATE TABLE ll_selfop_inventory_daily (
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
@@ -4530,32 +4501,30 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
USE baiyue_lastleg;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
-PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;
-- Appointment work scope, audit and physical batch allocation.
USE baiyue_lastleg;
-CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
- dispatch_id BIGINT NOT NULL,
- dispatch_item_id BIGINT NOT NULL,
- plan_pallet_id BIGINT NOT NULL,
- batch_key VARCHAR(96) NOT NULL,
- customer_id VARCHAR(64) NOT NULL,
- source_key VARCHAR(160) NOT NULL,
- destination_key VARCHAR(160) NOT NULL,
- package_count INT NOT NULL,
+CREATE TABLE ll_dispatch_appointment_allocation (
+ dispatch_id BIGINT NOT NULL COMMENT '排车单标识',
+ dispatch_item_id BIGINT NOT NULL COMMENT '排车明细标识',
+ plan_pallet_id BIGINT NOT NULL COMMENT '入仓计划托盘标识',
+ batch_key VARCHAR(96) NOT NULL COMMENT '预约稳定批次键',
+ customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
+ source_key VARCHAR(160) NOT NULL COMMENT '货物来源稳定键',
+ destination_key VARCHAR(160) NOT NULL COMMENT '目的地稳定键',
+ package_count INT NOT NULL COMMENT '本票分配实物箱数',
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
KEY idx_dispatch_appointment_dispatch(dispatch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
-CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
- entity_key VARCHAR(160) NOT NULL,
+CREATE TABLE ll_appointment_work_commitment (
+ entity_key VARCHAR(160) NOT NULL COMMENT '原业务实体稳定键',
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
- impact_version BIGINT NOT NULL DEFAULT 0,
+ impact_version BIGINT NOT NULL DEFAULT 0 COMMENT '承诺影响单调递增版本',
PRIMARY KEY (entity_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
-CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
+CREATE TABLE ll_appointment_work_guard (
id INT NOT NULL COMMENT '工作事项协调锁,固定1',
scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标',
initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描',
@@ -4567,9 +4536,9 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫';
-INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard WHERE id=1);
+INSERT INTO ll_appointment_work_guard (id,initialized) VALUES (1,FALSE);
-CREATE TABLE IF NOT EXISTS ll_appointment_work (
+CREATE TABLE ll_appointment_work (
work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成',
container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号',
version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本',
@@ -4579,7 +4548,7 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work (
KEY idx_appointment_work_container (container_booking_id,retired)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系';
-CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
+CREATE TABLE ll_appointment_work_history (
id VARCHAR(36) NOT NULL COMMENT '历史记录标识',
work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键',
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识',
diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md
index 490707f70..0ec4d6a1e 100644
--- a/lastleg-service/docs/business-index.md
+++ b/lastleg-service/docs/business-index.md
@@ -44,7 +44,7 @@
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
-| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
+| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/PICKUP/AMAZON_PICKUP/CUSTOMER_PICKUP`,仅快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅平台卡车 `TRUCK` 进入 `WAIT_PRE_DISPATCH`;快递及普通自提、亚马逊自提、客户自提均直接创建换标出库单,普通库存仍复验实际托盘绑定,已分配仓储转运保留原转运出库链。客户确认前不得进入出库 |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
index 916aebf2c..52151dd76 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
@@ -244,7 +244,7 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
- "WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
+ "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"AND a.customer_id=#{customerId} " +
"AND a.relabel_no=#{relabelNo} " +
"AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) " +
@@ -271,7 +271,7 @@ public interface DispatchCandidateMapper {
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
"AND c.relabel_application_id=a.id AND c.overseas_warehouse_id=a.overseas_warehouse_id " +
- "WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
+ "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL " +
"AND a.customer_id=#{customerId} " +
"AND a.relabel_no=#{relabelNo} " +
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
index ab620e3e3..a7b60cf4e 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
@@ -116,8 +116,8 @@ public class DispatchService {
if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
- if ("EXPRESS".equals(application.getDeliveryMethod())) {
- throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单为快递派送,无需进行排车");
+ if (!"TRUCK".equals(application.getDeliveryMethod())) {
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "只有平台卡车派送的换标单可以排车");
}
return application.getCustomerId();
}
@@ -607,10 +607,11 @@ public class DispatchService {
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
+ if (!"TRUCK".equals(application.getDeliveryMethod())
+ || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
relabelBindingGuard.requireBound(application);
relabelMeasurements.requireMeasured(application);
- if ("EXPRESS".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
- throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
List selected = java.util.stream.Stream.concat(
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList();
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java
index ba464a0d4..272ac6222 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java
@@ -512,7 +512,7 @@ public class PdaRelabelService {
updateTask(task, request.expectedVersion());
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""),
- "manualCompletion", request.manualCompletion()));
+ "manualCompletion", request.manualCompletion(), "roundKey", mixedRound(task)));
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
applicationService.settlePdaCompletion(application.getCustomerId(), relabelNo, operatorKey);
@@ -816,7 +816,15 @@ public class PdaRelabelService {
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
- itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), !"EXPRESS".equals(application.getDeliveryMethod()));
+ itemResponses, factResponses, historyResponses, application.getDeliveryMethod(),
+ detailItems.stream().anyMatch(item -> {
+ String operation = operationType(item);
+ if (item.getReturnCartonId() == null)
+ return Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL").contains(operation);
+ if ("SKU_LABEL".equals(operation) && application.getDeliveryMethod() == null) return false;
+ return !"EXPRESS".equals(application.getDeliveryMethod()) && !"PICKUP".equals(application.getDeliveryMethod())
+ && !"AMAZON_PICKUP".equals(application.getDeliveryMethod()) && !"CUSTOMER_PICKUP".equals(application.getDeliveryMethod());
+ }));
}
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
index ccacfa2c7..11da41ef8 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
@@ -94,6 +94,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
+ @Resource private RelabelBindingGuard bindingGuard;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper skuConversionRows;
@Resource private ReturnOrderMapper returnOrders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper returnPackingTraces;
@@ -368,7 +369,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|| request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod());
if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) {
- throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed");
+ throw badRequest("箱唛换标请选择平台卡车、快递、亚马逊自提、客户自提或自提");
}
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
throw badRequest("SKU-only relabel does not create an outbound fulfillment");
@@ -933,6 +934,8 @@ public class RelabelApplicationService implements WalletResultHandler {
throw conflict("Upload the shipping label before confirming relabel completion");
}
List relabelItems = itemList(application.getId());
+ // Manual warehouse completion does not waive physical binding at customer confirmation.
+ bindingGuard.requireBound(application);
skuConversions.confirm(application);
WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null
: warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
@@ -940,6 +943,10 @@ public class RelabelApplicationService implements WalletResultHandler {
&& StringUtils.hasText(linkedTransfer.getAllocationIdempotencyKey());
boolean requiresOutbound = relabelItems.stream()
.anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
+ boolean directOutbound = "EXPRESS".equals(application.getDeliveryMethod())
+ || "PICKUP".equals(application.getDeliveryMethod())
+ || "AMAZON_PICKUP".equals(application.getDeliveryMethod())
+ || "CUSTOMER_PICKUP".equals(application.getDeliveryMethod());
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED");
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
@@ -951,9 +958,9 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setCustomerConfirmationKey(key);
application.setCustomerConfirmedAt(now);
application.setFulfillmentStatus(requiresOutbound
- ? ("EXPRESS".equals(application.getDeliveryMethod()) ? "WAIT_OUTBOUND_CREATION" : "WAIT_PRE_DISPATCH")
+ ? (directOutbound ? "WAIT_OUTBOUND_CREATION" : "WAIT_PRE_DISPATCH")
: "NOT_REQUIRED");
- if (requiresOutbound && !existingStockTransfer && "EXPRESS".equals(application.getDeliveryMethod())) {
+ if (requiresOutbound && !existingStockTransfer && directOutbound) {
createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED");
}
@@ -993,7 +1000,7 @@ public class RelabelApplicationService implements WalletResultHandler {
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
// 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。
// 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。
- boolean awaitingTruck = requiresOutbound && !"EXPRESS".equals(application.getDeliveryMethod());
+ boolean awaitingTruck = requiresOutbound && !directOutbound;
if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) {
carton.setRelabelApplicationId(null); carton.setRelabelNo(null);
}
@@ -1050,6 +1057,7 @@ public class RelabelApplicationService implements WalletResultHandler {
boolean relabelHasDestination = !relabelItems.isEmpty() && relabelItems.stream()
.allMatch(item -> StringUtils.hasText(item.getDestinationFbaWarehouseCode()));
application.setFulfillmentStatus(existingStockTransfer ? "WAIT_TRANSFER_OUTBOUND"
+ : StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_CREATED"
: "READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination
? "WAIT_PRE_DISPATCH" : transferStatus);
applications.updateById(application);
@@ -1085,6 +1093,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (source.getReturnCartonId() == null) continue;
ReturnInventoryCarton carton = returnCartons.lockId(source.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
+ || !Objects.equals(application.getOverseasWarehouseId(), carton.getOverseasWarehouseId())
|| !application.getId().equals(carton.getRelabelApplicationId())
|| !"RELABEL_PENDING".equals(carton.getStatus())
|| intValue(source.getNotifiedPackageCount()) != 1)
@@ -1103,7 +1112,8 @@ public class RelabelApplicationService implements WalletResultHandler {
this::outboundSourceKey, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry> entry : byPosition.entrySet()) {
InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null;
- if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) {
+ if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId())
+ || !Objects.equals(application.getOverseasWarehouseId(), position.getOverseasWarehouseId()))) {
throw conflict("Relabel source inventory position no longer exists");
}
List group = entry.getValue();
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java
index 1b45fe7cd..0ceef07f3 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java
@@ -22,6 +22,7 @@ public class RelabelBindingGuard {
@Resource private PdaRelabelHistoryMapper histories;
@Resource private InventoryPositionMapper positions;
@Resource private ReturnInventoryCartonMapper returnCartons;
+ @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper conversions;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnLines;
@Resource private ObjectMapper objectMapper;
@@ -32,10 +33,15 @@ public class RelabelBindingGuard {
.contains(item.getOperationType())).toList();
if (cartons.isEmpty()) return;
Map> bindings = new HashMap<>();
+ String currentRound = null;
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())
.orderByAsc("created_at", "id"))) {
- if ("REWORK_ARCHIVE".equals(history.getAction())) bindings.clear();
+ if ("REWORK_ARCHIVE".equals(history.getAction())) { bindings.clear(); currentRound = null; }
+ if ("COMPLETE".equals(history.getAction())) {
+ try { currentRound = objectMapper.readTree(history.getDetailJson()).path("roundKey").asText(null); }
+ catch (Exception failure) { throw conflict("换标完工轮次记录无法读取"); }
+ }
if (!"PALLET_BIND".equals(history.getAction())) continue;
try {
var data = objectMapper.readTree(history.getDetailJson());
@@ -45,20 +51,26 @@ public class RelabelBindingGuard {
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
}
for (RelabelItem item : cartons) {
+ var sourceCarton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
+ if (item.getReturnCartonId() != null && (sourceCarton == null
+ || !Objects.equals(app.getCustomerId(), sourceCarton.getCustomerId())
+ || !Objects.equals(app.getOverseasWarehouseId(), sourceCarton.getOverseasWarehouseId())))
+ throw conflict("退货箱来源缺失或仓库不一致");
if (item.getReturnCartonId() != null && !Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType())) {
- var carton = returnCartons.selectById(item.getReturnCartonId());
- if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失");
+ var carton = sourceCarton;
if ("CARTON_AND_SKU".equals(item.getOperationType())) {
var expected = new HashMap();
try {
for (var sku : objectMapper.readTree(item.getSkuRelabelItemsJson()))
expected.merge(sku.path("skuCode").asText().toUpperCase(Locale.ROOT), sku.path("quantity").asLong(), Long::sum);
} catch (Exception failure) { throw conflict("混贴 SKU 明细无法读取"); }
+ projectCurrentContents(app, List.of(item), expected, currentRound);
var actual = new HashMap();
returnLines.lockCarton(item.getReturnCartonId()).forEach(line -> actual.merge(line.getSkuCode().toUpperCase(Locale.ROOT), line.getQuantity(), Long::sum));
if (!expected.equals(actual)) throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱");
}
- if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
+ if ("EXPRESS".equals(app.getDeliveryMethod()) || "PICKUP".equals(app.getDeliveryMethod())
+ || "AMAZON_PICKUP".equals(app.getDeliveryMethod()) || "CUSTOMER_PICKUP".equals(app.getDeliveryMethod())) continue;
if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()))
throw conflict("退货箱尚未完成排车托盘绑定");
continue;
@@ -75,20 +87,54 @@ public class RelabelBindingGuard {
}
var actual = new HashMap();
stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum));
+ projectCurrentContents(app, sameCarton, selected, currentRound);
if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工");
- if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
- var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
- if (carton == null || carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
+ // SKU-only work has no outbound fulfillment, but must still cover the isolated carton.
+ if ("SKU_LABEL".equals(item.getOperationType()) && app.getDeliveryMethod() == null) continue;
+ if ("EXPRESS".equals(app.getDeliveryMethod()) || "PICKUP".equals(app.getDeliveryMethod())
+ || "AMAZON_PICKUP".equals(app.getDeliveryMethod()) || "CUSTOMER_PICKUP".equals(app.getDeliveryMethod())) continue;
+ var carton = sourceCarton;
+ if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|| !"PROCESSING".equals(carton.getRelabelStatus()))
throw conflict("退货箱 " + item.getCartonMark() + " 尚未绑定带目的仓的新托盘,不能完成换标");
continue;
}
var position = item.getSourceInventoryPositionId() == null ? null
: positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId());
- if (position == null || position.getPalletNo() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
+ if (position == null || !Objects.equals(app.getOverseasWarehouseId(), position.getOverseasWarehouseId())
+ || position.getPalletNo() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
|| position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount())
throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库");
}
}
+
+ private void projectCurrentContents(RelabelApplication app, List selectedItems,
+ Map expected, String currentRound) {
+ if (!"CUSTOMER_CONFIRM_PENDING".equals(app.getStatus()) && !"READY_FOR_OUTBOUND".equals(app.getStatus())
+ && !"COMPLETED".equals(app.getStatus())) return;
+ if (currentRound == null || currentRound.isBlank()) throw conflict("换标完工轮次缺失,不能核对当前 SKU 库存");
+ // Warehouse completion has already applied SKU conversions. Confirmation and dispatch
+ // compare against that round's persisted changes, never assume that every new SKU exists.
+ Map outgoing = new HashMap<>(), incoming = new HashMap<>();
+ for (var row : conversions.lockApplication(app.getCustomerId(), app.getId())) {
+ if (!Objects.equals(row.getCustomerId(), app.getCustomerId()) || !Objects.equals(row.getApplicationId(), app.getId())
+ || !Objects.equals(row.getRoundKey(), currentRound)
+ || !("APPLIED".equals(row.getStatus()) || "CONFIRMED".equals(row.getStatus()))
+ || selectedItems.stream().noneMatch(item -> Objects.equals(item.getId(), row.getItemId())
+ && Objects.equals(item.getReturnCartonId(), row.getCartonId()))) continue;
+ String oldSku = row.getOldSkuCode().toUpperCase(Locale.ROOT);
+ String newSku = row.getNewSkuCode().toUpperCase(Locale.ROOT);
+ long quantity = Objects.requireNonNullElse(row.getQuantity(), 0L);
+ if (quantity <= 0) throw conflict("换标转换数量与批准明细不一致");
+ outgoing.merge(oldSku, quantity, Math::addExact);
+ incoming.merge(newSku, quantity, Math::addExact);
+ }
+ for (var source : outgoing.entrySet()) {
+ long remaining = expected.getOrDefault(source.getKey(), 0L) - source.getValue();
+ if (remaining < 0) throw conflict("换标转换数量与批准明细不一致");
+ if (remaining == 0) expected.remove(source.getKey()); else expected.put(source.getKey(), remaining);
+ }
+ incoming.forEach((sku, quantity) -> expected.merge(sku, quantity, Math::addExact));
+ }
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
diff --git a/lastleg-service/src/main/resources/db/appointment-work.sql b/lastleg-service/src/main/resources/db/appointment-work.sql
index 66cce1014..3db17dc47 100644
--- a/lastleg-service/src/main/resources/db/appointment-work.sql
+++ b/lastleg-service/src/main/resources/db/appointment-work.sql
@@ -1,21 +1,21 @@
-- DDL proposal only. The coordinator must integrate the approved baseline; do not execute from this task.
CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
- dispatch_id BIGINT NOT NULL,
- dispatch_item_id BIGINT NOT NULL,
- plan_pallet_id BIGINT NOT NULL,
- batch_key VARCHAR(96) NOT NULL,
- customer_id VARCHAR(64) NOT NULL,
- source_key VARCHAR(160) NOT NULL,
- destination_key VARCHAR(160) NOT NULL,
- package_count INT NOT NULL,
+ dispatch_id BIGINT NOT NULL COMMENT '排车单标识',
+ dispatch_item_id BIGINT NOT NULL COMMENT '排车明细标识',
+ plan_pallet_id BIGINT NOT NULL COMMENT '入仓计划托盘标识',
+ batch_key VARCHAR(96) NOT NULL COMMENT '预约稳定批次键',
+ customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
+ source_key VARCHAR(160) NOT NULL COMMENT '货物来源稳定键',
+ destination_key VARCHAR(160) NOT NULL COMMENT '目的地稳定键',
+ package_count INT NOT NULL COMMENT '本票分配实物箱数',
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
KEY idx_dispatch_appointment_dispatch(dispatch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
- entity_key VARCHAR(160) NOT NULL,
+ entity_key VARCHAR(160) NOT NULL COMMENT '原业务实体稳定键',
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
- impact_version BIGINT NOT NULL DEFAULT 0,
+ impact_version BIGINT NOT NULL DEFAULT 0 COMMENT '承诺影响单调递增版本',
PRIMARY KEY (entity_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql
index f4053b04e..fd865eb5c 100644
--- a/lastleg-service/src/main/resources/db/lastleg.sql
+++ b/lastleg-service/src/main/resources/db/lastleg.sql
@@ -2210,6 +2210,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order (
) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item (
+ declared_quantity BIGINT NULL COMMENT 'SKU申报件数,非预计或实收箱数',
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称',
package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头',
@@ -2218,8 +2219,21 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item (
) ENGINE=InnoDB COMMENT='转仓货物冻结明细';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
+ length_cm DECIMAL(12,2) NULL COMMENT '箱子实测长度厘米',
+ width_cm DECIMAL(12,2) NULL COMMENT '箱子实测宽度厘米',
+ height_cm DECIMAL(12,2) NULL COMMENT '箱子实测高度厘米',
+ gross_weight_kg DECIMAL(12,2) NULL COMMENT '箱子实测重量千克',
+ counted_quantity BIGINT NULL COMMENT '箱内SKU实点件数',
+ count_idempotency_key VARCHAR(255) NULL COMMENT '箱内清点幂等键',
+ count_request_hash VARCHAR(64) NULL COMMENT '箱内清点请求摘要',
+ counted_by VARCHAR(128) NULL COMMENT '箱内清点操作人',
+ counted_at DATETIME(3) NULL COMMENT '箱内清点完成时间',
+ discrepancy_reason VARCHAR(1000) NULL COMMENT '实收与预报箱数差异原因',
+ transfer_pallet_id BIGINT NULL COMMENT '签收箱所属转运托盘事实ID',
+ sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细',
id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
+ customer_carton_mark VARCHAR(255) NULL COMMENT '客户原箱唛,用于换标现场绑定',
carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号',
total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -2370,7 +2384,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名',
contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
- delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填',
+ delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK平台卡车、EXPRESS快递、PICKUP自提、AMAZON_PICKUP亚马逊自提、CUSTOMER_PICKUP客户自提;仅平台卡车进入预排车',
print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单',
shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID',
shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名',
@@ -2407,7 +2421,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID'' AFTER remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名'' AFTER total_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址'' AFTER total_file_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_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK平台卡车、EXPRESS快递、PICKUP自提、AMAZON_PICKUP亚马逊自提、CUSTOMER_PICKUP客户自提;仅平台卡车进入预排车'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -2662,6 +2676,8 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
+,
+ KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
@@ -3981,21 +3997,21 @@ CREATE TABLE ll_selfop_sku_profile (
-- PDA cargo discard photo evidence
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
- id BIGINT NOT NULL,
- customer_id VARCHAR(64) NOT NULL,
- application_id BIGINT NOT NULL,
- pallet_no VARCHAR(128) NOT NULL,
- file_id BIGINT NOT NULL,
- file_name VARCHAR(255) NOT NULL,
- content_type VARCHAR(128) NOT NULL,
- size_bytes BIGINT NOT NULL,
- operator_id VARCHAR(64) NOT NULL,
- created_at DATETIME(6) NOT NULL,
- submitted_at DATETIME(6) NULL,
+ id BIGINT NOT NULL COMMENT '照片记录标识',
+ customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
+ application_id BIGINT NOT NULL COMMENT '废弃申请标识',
+ pallet_no VARCHAR(128) NOT NULL COMMENT '托盘业务码',
+ file_id BIGINT NOT NULL COMMENT '照片文件标识',
+ file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
+ content_type VARCHAR(128) NOT NULL COMMENT '文件媒体类型',
+ size_bytes BIGINT NOT NULL COMMENT '文件字节数',
+ operator_id VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
+ created_at DATETIME(6) NOT NULL COMMENT '照片创建时间',
+ submitted_at DATETIME(6) NULL COMMENT '照片提交时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA货物废弃照片凭证';
CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键',
@@ -4154,22 +4170,22 @@ PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPA
-- Appointment work scope, audit and physical batch allocation.
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
- dispatch_id BIGINT NOT NULL,
- dispatch_item_id BIGINT NOT NULL,
- plan_pallet_id BIGINT NOT NULL,
- batch_key VARCHAR(96) NOT NULL,
- customer_id VARCHAR(64) NOT NULL,
- source_key VARCHAR(160) NOT NULL,
- destination_key VARCHAR(160) NOT NULL,
- package_count INT NOT NULL,
+ dispatch_id BIGINT NOT NULL COMMENT '排车单标识',
+ dispatch_item_id BIGINT NOT NULL COMMENT '排车明细标识',
+ plan_pallet_id BIGINT NOT NULL COMMENT '入仓计划托盘标识',
+ batch_key VARCHAR(96) NOT NULL COMMENT '预约稳定批次键',
+ customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
+ source_key VARCHAR(160) NOT NULL COMMENT '货物来源稳定键',
+ destination_key VARCHAR(160) NOT NULL COMMENT '目的地稳定键',
+ package_count INT NOT NULL COMMENT '本票分配实物箱数',
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
KEY idx_dispatch_appointment_dispatch(dispatch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
- entity_key VARCHAR(160) NOT NULL,
+ entity_key VARCHAR(160) NOT NULL COMMENT '原业务实体稳定键',
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
- impact_version BIGINT NOT NULL DEFAULT 0,
+ impact_version BIGINT NOT NULL DEFAULT 0 COMMENT '承诺影响单调递增版本',
PRIMARY KEY (entity_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
index c86721ade..bda885e5a 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
@@ -1710,7 +1710,7 @@ paths:
'200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } }
post:
summary: 创建客户换标申请
- description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。一张申请只能选择一种换标类型,所有 items.operationType 必须一致;不同类型混合提交返回业务参数错误且不冻结库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
+ description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。一张申请只能选择一种换标类型,所有 items.operationType 必须一致;不同类型混合提交返回业务参数错误且不冻结库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、EXPRESS、PICKUP、AMAZON_PICKUP 或 CUSTOMER_PICKUP。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } }
responses:
@@ -1791,7 +1791,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion:
post:
summary: 客户确认换标完成
- description: 权限 merchant:overseas-warehouse:relabel:confirm;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。
+ description: 权限 merchant:overseas-warehouse:relabel:confirm;仅 CUSTOMER_CONFIRM_PENDING 可确认,确认前复验实际托盘绑定,仓库手动完工不豁免该门禁。普通库存箱唛换标必须绑定实际托盘;退货整箱 EXPRESS、PICKUP、AMAZON_PICKUP、CUSTOMER_PICKUP 可不组托但仍校验全箱 SKU 数量。只有平台卡车 TRUCK 进入 WAIT_PRE_DISPATCH;EXPRESS 与全部自提方式幂等生成 RELABEL_OUTBOUND 出库单和 PDA 出库任务,不进入预排车候选,直接预排请求也拒绝。已分配仓储转运保持原转运出库链,不生成独立出库。EXPRESS 且要求打印面单时必须先上传面单。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } }
@@ -3154,7 +3154,7 @@ components:
notifiedPackageCount: { type: integer }
productNameZh: { type: string, description: 明细非空中文品名按原顺序去重并以顿号连接 }
labelRequirement: { type: string, description: 明细非空贴标要求按原顺序去重并以中文分号连接 }
- deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] }
+ deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, PICKUP, AMAZON_PICKUP, CUSTOMER_PICKUP], description: 仅TRUCK进入平台预排车;快递及全部自提直接创建出库任务 }
printShippingLabel: { type: boolean }
shippingLabelFileId: { type: string, nullable: true }
fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, WAIT_TRANSFER_OUTBOUND, READY_FOR_OUTBOUND, OUTBOUND_CREATED, OUTBOUNDED, COMPLETED] }
@@ -3293,7 +3293,7 @@ components:
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
- deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
+ deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传;仅TRUCK进入平台预排车,其余方式直接创建出库任务 }
printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 }
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
RelabelItem:
diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml
index 91f33e376..8370848bd 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml
@@ -858,8 +858,8 @@ components:
type: object
required: [relabelNo, applicationStatus, operationType, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
properties:
- deliveryMethod: { type: string, nullable: true, description: 申请真实运输方式,留仓为空 }
- requiresPalletBinding: { type: boolean, description: 退货任务仅TRUCK为true,留仓及快递为false }
+ deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, PICKUP, AMAZON_PICKUP, CUSTOMER_PICKUP], description: 申请真实运输方式,纯SKU留仓为空;只有平台卡车TRUCK进入预排车,快递和所有自提直接创建出库任务 }
+ requiresPalletBinding: { type: boolean, description: 普通库存箱唛或混合换标须实际托盘绑定;退货整箱EXPRESS、PICKUP、AMAZON_PICKUP、CUSTOMER_PICKUP不要求排车组托,TRUCK要求;纯SKU留仓为空派送方式时不要求组托 }
relabelNo: { type: string }
customerOrderNo: { type: string, nullable: true }
labelDeadline: { type: string, format: date-time, nullable: true }
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/contract/AppointmentWorkSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/contract/AppointmentWorkSchemaContractTest.java
index be9f6ab1d..b1d98e3a0 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/contract/AppointmentWorkSchemaContractTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/contract/AppointmentWorkSchemaContractTest.java
@@ -13,7 +13,12 @@ class AppointmentWorkSchemaContractTest {
String target=Files.readString(Path.of(path));
for(String table:List.of("ll_dispatch_appointment_allocation","ll_appointment_work_commitment","ll_appointment_work_guard","ll_appointment_work","ll_appointment_work_history"))
assertEquals(definition(source,table),definition(target,table),path+" differs for "+table);
- assertTrue(target.contains("INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE"));
+ if (path.startsWith("../deploy/")) {
+ assertTrue(target.contains("INSERT INTO ll_appointment_work_guard (id,initialized) VALUES (1,FALSE);"));
+ assertFalse(target.contains("WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard"));
+ } else {
+ assertTrue(target.contains("INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE"));
+ }
}
assertTrue(source.contains("scan_cursor VARCHAR(128)"));
assertTrue(source.contains("PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key)"));
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java
index 2759a940f..af62cb734 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java
@@ -16,6 +16,32 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DispatchCandidateMapperContractTest {
+ @Test
+ void ordinaryAndReturnRelabelCandidatesExcludeDirectOutboundDeliveryModes() throws Exception {
+ for (String method : List.of("relabelAvailable", "nativeReturnRelabelAvailable")) {
+ String sql = String.join(" ", DispatchCandidateMapper.class
+ .getMethod(method, String.class, String.class, String.class).getAnnotation(Select.class).value());
+ String eligibility = sql.substring(sql.indexOf("WHERE a.delivery_method"), sql.indexOf(" service.resolveCustomerScopeForRelabel("RL-1"));
+ assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP",
+ new DispatchRequests.PreArrange(List.of(1L), null, null, null, null, "RL-1")));
+ verify(orders, never()).insert(any(DispatchOrder.class));
+ verify(candidates, never()).relabelAvailable(any(), any(), any());
+ verify(relabelMeasurements, never()).requireMeasured(any());
+ }
+
@Mock DispatchOrderMapper orders;
@Mock DispatchItemMapper items;
@Mock DispatchBolMapper bolLines;
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java
index bcdbd02be..0b27bde80 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java
@@ -44,6 +44,20 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaRelabelServiceTest {
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "TRUCK", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
+ void bindingRequirementDistinguishesOrdinaryInventoryFromDirectReturnCartons(String deliveryMethod) {
+ application.setDeliveryMethod(deliveryMethod); item.setOperationType("CARTON_LABEL");
+ when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
+ assertTrue(service.detail("7", 900L, "FBR-1").requiresPalletBinding());
+ var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
+ ReflectionTestUtils.setField(service, "returnCartons", cartons);
+ item.setReturnCartonId(40L);
+ assertEquals("TRUCK".equals(deliveryMethod), service.detail("7", 900L, "FBR-1").requiresPalletBinding());
+ application.setDeliveryMethod(null); item.setOperationType("SKU_LABEL");
+ assertEquals(false, service.detail("7", 900L, "FBR-1").requiresPalletBinding());
+ }
+
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@Mock PdaRelabelTaskMapper tasks;
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
index a0be82d99..a3990662b 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
@@ -192,6 +192,7 @@ class RelabelApplicationServiceTest {
}
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
+ @Mock RelabelBindingGuard bindingGuard;
@Mock RelabelStatusHistoryMapper histories;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@@ -214,6 +215,7 @@ class RelabelApplicationServiceTest {
@BeforeEach
void setUp() {
service = new RelabelApplicationService();
+ ReflectionTestUtils.setField(service, "bindingGuard", bindingGuard);
ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class));
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
ReflectionTestUtils.setField(service, "skuConversionRows", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
@@ -970,9 +972,11 @@ class RelabelApplicationServiceTest {
verify(applications, never()).updateById(application);
}
- @Test
- void pickupCustomerConfirmationCreatesOneExactCartonOutbound() {
- RelabelApplication application = confirmationApplication("PICKUP", false);
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.CsvSource({"PICKUP,false", "EXPRESS,false", "AMAZON_PICKUP,false", "CUSTOMER_PICKUP,false",
+ "PICKUP,true", "EXPRESS,true", "AMAZON_PICKUP,true", "CUSTOMER_PICKUP,true"})
+ void directCustomerConfirmationCreatesOneExactCartonOutboundAndReplays(String deliveryMethod, boolean linkedTransfer) {
+ RelabelApplication application = confirmationApplication(deliveryMethod, false);
RelabelItem source = item(31L, application.getId(), "SKU-A", 1, 1);
source.setSourceInventoryPositionId(201L);
source.setSourceInboundCode("CODE-1");
@@ -980,13 +984,21 @@ class RelabelApplicationServiceTest {
source.setDestinationFbaWarehouseCode("ONT8");
InventoryPosition position = new InventoryPosition();
position.setId(201L); position.setCustomerId("10001"); position.setPalletId(501L); position.setPalletNo("PALLET-1");
+ position.setOverseasWarehouseId("900");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(source));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"));
when(inventoryPositions.selectById(201L)).thenReturn(position);
+ if (linkedTransfer) {
+ var transfer = new WarehouseTransferOrder(); transfer.setId(77L); transfer.setCustomerId("10001");
+ transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8");
+ when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
+ }
+ service.confirmCompletion("10001", "FBR-1", "customer-1",
+ new RelabelRequests.CustomerConfirm("confirm-pickup", null));
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-pickup", null));
@@ -1000,6 +1012,87 @@ class RelabelApplicationServiceTest {
assertEquals("BOX-1", outboundItem.getValue().getCartonMarks());
assertEquals("PALLET-1", outboundItem.getValue().getPalletNo());
verify(outboundTasks, times(1)).insert(any(OutboundPdaTask.class));
+ verify(bindingGuard, times(1)).requireBound(application);
+ }
+
+ @Test
+ void customerConfirmationRejectsUnboundManualCompletionBeforeAnyWrite() {
+ RelabelApplication application = confirmationApplication("PICKUP", false);
+ when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
+ org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,
+ "箱唛 BOX-1 尚未完成托盘绑定")).when(bindingGuard).requireBound(application);
+ assertThrows(ResponseStatusException.class, () -> service.confirmCompletion("10001", "FBR-1", "customer-1",
+ new RelabelRequests.CustomerConfirm("manual-confirm", null)));
+ verify(pdaTasks, never()).updateWithVersion(any(), any());
+ verify(applications, never()).updateById(any(RelabelApplication.class));
+ verify(outboundOrders, never()).insert(any(OutboundOrder.class));
+ verify(outboundItems, never()).insert(any(OutboundPalletItem.class));
+ verify(outboundTasks, never()).insert(any(OutboundPdaTask.class));
+ assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, application.getStatus());
+ }
+
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.CsvSource({"SKU_LABEL,", "CARTON_AND_SKU,PICKUP", "CARTON_AND_SKU,EXPRESS",
+ "CARTON_AND_SKU,AMAZON_PICKUP", "CARTON_AND_SKU,CUSTOMER_PICKUP"})
+ void convertedReturnContentsPassRealConfirmationGuardAndRemainValidAfterConfirmation(String operation, String deliveryMethod) {
+ var application = confirmationApplication(deliveryMethod, false);
+ var source = item(31L, application.getId(), "OLD-A", 1, 1);
+ source.setOperationType(operation); source.setReturnCartonId(301L); source.setSourceInventoryPositionId(null);
+ source.setCartonMark("BOX-1"); source.setSourceCartonMark("RETURN-BOX-1");
+ source.setDestinationFbaWarehouseCode("ONT8");
+ source.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW\",\"quantity\":2}]");
+ when(items.selectList(any())).thenReturn(List.of(source));
+ when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
+ when(applications.findNo("10001", "FBR-1")).thenReturn(application);
+ when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL"));
+ var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
+ var contents = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
+ var conversions = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class);
+ var pdaHistories = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
+ var completed = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
+ completed.setAction("COMPLETE"); completed.setDetailJson("{\"roundKey\":\"INITIAL\"}");
+ when(pdaHistories.selectList(any())).thenReturn(List.of(completed));
+ var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
+ carton.setId(301L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("900");
+ carton.setRelabelApplicationId(application.getId()); carton.setStatus("RELABEL_PENDING");
+ carton.setCountStatus("CONFIRMED"); carton.setSourceUnitType("BOX"); carton.setTotalQuantity(5L);
+ lenient().when(cartons.selectById(301L)).thenReturn(carton);
+ when(cartons.lockId(301L)).thenReturn(carton);
+ var content = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
+ content.setSkuCode("NEW"); content.setQuantity(5L);
+ when(contents.lockCarton(301L)).thenReturn(List.of(content));
+ var rows = new java.util.ArrayList();
+ for (var oldSku : List.of("OLD-A", "OLD-B")) {
+ var row = new com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion();
+ row.setCustomerId("10001"); row.setApplicationId(application.getId()); row.setItemId(source.getId());
+ row.setCartonId(301L); row.setRoundKey("INITIAL"); row.setStatus("APPLIED");
+ row.setOldSkuCode(oldSku); row.setNewSkuCode("NEW"); row.setQuantity("OLD-A".equals(oldSku) ? 3L : 2L);
+ rows.add(row);
+ }
+ when(conversions.lockApplication("10001", application.getId())).thenReturn(rows);
+ var realGuard = new RelabelBindingGuard();
+ ReflectionTestUtils.setField(realGuard, "items", items); ReflectionTestUtils.setField(realGuard, "histories", pdaHistories);
+ ReflectionTestUtils.setField(realGuard, "returnCartons", cartons); ReflectionTestUtils.setField(realGuard, "returnLines", contents);
+ ReflectionTestUtils.setField(realGuard, "conversions", conversions); ReflectionTestUtils.setField(realGuard, "objectMapper", mapper());
+ var realConversions = new ReturnSkuConversionService(); ReflectionTestUtils.setField(realConversions, "conversions", conversions);
+ ReflectionTestUtils.setField(service, "bindingGuard", realGuard); ReflectionTestUtils.setField(service, "skuConversions", realConversions);
+ ReflectionTestUtils.setField(service, "returnCartons", cartons);
+ ReflectionTestUtils.setField(service, "deliveryOrders", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper.class));
+
+ var command = new RelabelRequests.CustomerConfirm("converted-confirm", null);
+ service.confirmCompletion("10001", "FBR-1", "customer-1", command);
+ service.confirmCompletion("10001", "FBR-1", "customer-1", command);
+ assertTrue(rows.stream().allMatch(row -> "CONFIRMED".equals(row.getStatus())));
+ realGuard.requireBound(application);
+ verify(conversions, times(2)).updateById(any(com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion.class));
+ if ("SKU_LABEL".equals(operation)) {
+ assertEquals("COMPLETED", application.getStatus()); assertEquals("NOT_REQUIRED", application.getFulfillmentStatus());
+ verify(outboundOrders, never()).insert(any(OutboundOrder.class));
+ } else {
+ assertEquals("OUTBOUND_CREATED", application.getFulfillmentStatus());
+ verify(outboundOrders, times(1)).insert(any(OutboundOrder.class));
+ assertEquals("OUTBOUND_PENDING", carton.getStatus());
+ }
}
private RelabelApplication confirmationApplication(String deliveryMethod, boolean printShippingLabel) {
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java
index 640ed515a..d842982b7 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java
@@ -17,7 +17,74 @@ import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class RelabelBindingGuardTest {
- @Test void parcelMixedCompletionRechecksAllContentsWithoutRequiringExpressPallet() {
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL"})
+ void convertedContentsRequireCurrentCustomerApplicationItemCartonAndRound(String operation) {
+ var guard = new RelabelBindingGuard();
+ var items = mock(RelabelItemMapper.class); var history = mock(PdaRelabelHistoryMapper.class);
+ var cartons = mock(ReturnInventoryCartonMapper.class);
+ var contents = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
+ var conversions = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class);
+ ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", history);
+ ReflectionTestUtils.setField(guard, "returnCartons", cartons); ReflectionTestUtils.setField(guard, "returnLines", contents);
+ ReflectionTestUtils.setField(guard, "conversions", conversions); ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
+ var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setOverseasWarehouseId("W");
+ app.setStatus("CUSTOMER_CONFIRM_PENDING"); app.setDeliveryMethod("PICKUP");
+ var item = new RelabelItem(); item.setId(4L); item.setReturnCartonId(3L); item.setOperationType(operation);
+ item.setCartonMark("BOX"); item.setSkuCode("A"); item.setNewSkuCode("B"); item.setNotifiedPackageCount(3);
+ item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"B\",\"quantity\":3}]");
+ var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
+ carton.setCustomerId("2"); carton.setOverseasWarehouseId("W");
+ var complete = new PdaRelabelHistory(); complete.setAction("COMPLETE"); complete.setDetailJson("{\"roundKey\":\"ROUND-2\"}");
+ var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); line.setSkuCode("B"); line.setQuantity(3L);
+ var row = conversion("A", "B", 3L);
+ when(items.selectList(any())).thenReturn(List.of(item)); when(history.selectList(any())).thenReturn(List.of(complete));
+ when(cartons.selectById(3L)).thenReturn(carton); when(contents.lockCarton(3L)).thenReturn(List.of(line));
+ when(conversions.lockApplication("2", 1L)).thenReturn(List.of(row));
+ assertDoesNotThrow(() -> guard.requireBound(app));
+ row.setStatus("CONFIRMED"); app.setStatus("READY_FOR_OUTBOUND");
+ assertDoesNotThrow(() -> guard.requireBound(app));
+ row.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setCustomerId("2");
+ row.setApplicationId(9L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setApplicationId(1L);
+ row.setItemId(9L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setItemId(4L);
+ row.setCartonId(9L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setCartonId(3L);
+ row.setRoundKey("ROUND-1"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setRoundKey("ROUND-2");
+ row.setStatus("REVERSED"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setStatus("APPLIED");
+ line.setQuantity(2L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); line.setQuantity(3L);
+ carton.setOverseasWarehouseId("OTHER"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); carton.setOverseasWarehouseId("W");
+ carton.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); carton.setCustomerId("2");
+ when(conversions.lockApplication("2", 1L)).thenReturn(List.of());
+ assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
+ when(conversions.lockApplication("2", 1L)).thenReturn(List.of(row));
+ var archive = new PdaRelabelHistory(); archive.setAction("REWORK_ARCHIVE");
+ when(history.selectList(any())).thenReturn(List.of(complete, archive));
+ assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
+
+ // A -> B and original B -> C must subtract both original quantities before adding targets.
+ when(history.selectList(any())).thenReturn(List.of(complete));
+ item.setOperationType("CARTON_AND_SKU");
+ item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"B\",\"quantity\":3},{\"skuCode\":\"B\",\"newSkuCode\":\"C\",\"quantity\":2}]");
+ var second = conversion("B", "C", 2L);
+ var target = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); target.setSkuCode("C"); target.setQuantity(2L);
+ when(contents.lockCarton(3L)).thenReturn(List.of(line, target));
+ when(conversions.lockApplication("2", 1L)).thenReturn(List.of(second, row));
+ assertDoesNotThrow(() -> guard.requireBound(app));
+ when(conversions.lockApplication("2", 1L)).thenReturn(List.of(row, second));
+ assertDoesNotThrow(() -> guard.requireBound(app));
+ second.setQuantity(5L); line.setQuantity(0L); target.setQuantity(5L);
+ assertThrows(ResponseStatusException.class, () -> guard.requireBound(app), "不能把新转入 B 当作本申请原批准 B 数量");
+ }
+
+ private com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion conversion(String oldSku, String newSku, long quantity) {
+ var row = new com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion();
+ row.setCustomerId("2"); row.setApplicationId(1L); row.setItemId(4L); row.setCartonId(3L);
+ row.setRoundKey("ROUND-2"); row.setStatus("APPLIED"); row.setOldSkuCode(oldSku); row.setNewSkuCode(newSku); row.setQuantity(quantity);
+ return row;
+ }
+
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(strings = {"EXPRESS", "PICKUP", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
+ void parcelMixedCompletionRechecksAllContentsWithoutRequiringDirectOutboundPallet(String deliveryMethod) {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
@@ -25,7 +92,7 @@ class RelabelBindingGuardTest {
ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories);
ReflectionTestUtils.setField(guard, "returnCartons", cartons); ReflectionTestUtils.setField(guard, "returnLines", lines);
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
- var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setDeliveryMethod("EXPRESS");
+ var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setDeliveryMethod(deliveryMethod);
var item = new RelabelItem(); item.setReturnCartonId(3L); item.setOperationType("CARTON_AND_SKU");
item.setCartonMark("PARCEL-1"); item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"quantity\":3}]");
var parcel = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); parcel.setCustomerId("2"); parcel.setSourceUnitType("PARCEL");
@@ -51,14 +118,47 @@ class RelabelBindingGuardTest {
item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]");
when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of());
var stock = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); stock.setSkuCode("A"); stock.setQuantity(10L);
+ var ownedCarton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); ownedCarton.setCustomerId("2");
+ when(cartons.selectById(3L)).thenReturn(ownedCarton);
when(lines.lockCarton(3L)).thenReturn(List.of(stock));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
stock.setQuantity(3L);
assertDoesNotThrow(() -> guard.requireBound(app));
+ assertNull(ownedCarton.getPalletCode(), "纯 SKU 留仓不要求托盘,但仍校验来源归属");
+ stock.setSkuCode("OTHER");
+ assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
+ stock.setSkuCode("A");
app.setDeliveryMethod("EXPRESS"); assertDoesNotThrow(() -> guard.requireBound(app));
+ app.setDeliveryMethod("PICKUP"); assertDoesNotThrow(() -> guard.requireBound(app));
+ app.setDeliveryMethod("AMAZON_PICKUP"); assertDoesNotThrow(() -> guard.requireBound(app));
+ app.setDeliveryMethod("CUSTOMER_PICKUP"); assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("TRUCK"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
}
- @Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() {
+ @Test void returnBulkSkuWithoutDeliveryDoesNotBypassPalletBinding() {
+ var guard = new RelabelBindingGuard();
+ var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class);
+ var lines = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
+ var cartons = mock(ReturnInventoryCartonMapper.class);
+ ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories);
+ ReflectionTestUtils.setField(guard, "returnLines", lines); ReflectionTestUtils.setField(guard, "returnCartons", cartons);
+ ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
+ var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2");
+ var item = new RelabelItem(); item.setReturnCartonId(3L); item.setCartonMark("RETURN-BOX");
+ item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("A"); item.setNotifiedPackageCount(3);
+ var stock = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
+ stock.setSkuCode("A"); stock.setQuantity(3L);
+ when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of());
+ when(lines.lockCarton(3L)).thenReturn(List.of(stock));
+
+ var ownedCarton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); ownedCarton.setCustomerId("2");
+ when(cartons.selectById(3L)).thenReturn(ownedCarton);
+ var rejected = assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
+ assertTrue(rejected.getReason().contains("尚未绑定"));
+ verify(cartons).selectById(3L);
+ }
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(strings = {"TRUCK", "EXPRESS", "PICKUP", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
+ void physicalBindingMustMatchLatestTargetAndCannotSurviveRework(String deliveryMethod) {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class);
var history = mock(PdaRelabelHistoryMapper.class);
@@ -67,7 +167,7 @@ class RelabelBindingGuardTest {
ReflectionTestUtils.setField(guard, "histories", history);
ReflectionTestUtils.setField(guard, "positions", positions);
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
- var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2");
+ var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setDeliveryMethod(deliveryMethod);
var item = new RelabelItem(); item.setCartonMark("A"); item.setOperationType("CARTON_LABEL");
item.setSourceInventoryPositionId(3L); item.setNotifiedPackageCount(1);
when(items.selectList(any())).thenReturn(List.of(item));
@@ -81,6 +181,9 @@ class RelabelBindingGuardTest {
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
position.setPalletNo("NEW");
assertDoesNotThrow(() -> guard.requireBound(app));
+ position.setOverseasWarehouseId("OTHER");
+ assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
+ position.setOverseasWarehouseId(null);
var rework = new PdaRelabelHistory(); rework.setAction("REWORK_ARCHIVE");
when(history.selectList(any())).thenReturn(List.of(binding, rework));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java
index b8705c22b..1e4e35616 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java
@@ -16,7 +16,9 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ReturnRelabelDeliveryTest {
- @Test void directNativeRelabelCreatesTwoPhysicalBoxesAndOneDeliveryPerDestination() {
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(strings = {"EXPRESS", "PICKUP", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
+ void directNativeRelabelCreatesTwoPhysicalBoxesAndOneDeliveryPerDestination(String deliveryMethod) {
var service = new RelabelApplicationService();
var items = mock(RelabelItemMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
@@ -30,18 +32,20 @@ class ReturnRelabelDeliveryTest {
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1");
- application.setRelabelNo("REL1"); application.setOverseasWarehouseId("WH1"); application.setDeliveryMethod("EXPRESS");
+ application.setRelabelNo("REL1"); application.setOverseasWarehouseId("WH1"); application.setDeliveryMethod(deliveryMethod);
var a = source(10L, "A"); var b = source(11L, "B");
when(items.selectList(any())).thenReturn(List.of(a, b));
for (var item : List.of(a, b)) {
var carton = new ReturnInventoryCarton(); carton.setId(item.getReturnCartonId());
carton.setCustomerId("C1"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING");
+ carton.setOverseasWarehouseId("WH1");
when(cartons.lockId(carton.getId())).thenReturn(carton);
}
ReflectionTestUtils.invokeMethod(service, "createDirectOutbound", application, "OP1", LocalDateTime.now());
verify(deliveries, times(2)).insert(any(DeliveryOrder.class));
verify(deliveries).insert(argThat((DeliveryOrder d) -> "A".equals(d.getDestinationWarehouseCode())
&& d.getPackageCount() == 1 && "WAIT_DELIVERY".equals(d.getStatus())
+ && deliveryMethod.equals(d.getDeliveryMethod())
&& application.getOutboundOrderNo().equals(d.getOutboundNo())));
verify(deliveries).insert(argThat((DeliveryOrder d) -> "B".equals(d.getDestinationWarehouseCode()) && d.getPackageCount() == 1));
}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java
index 87bd5947e..fce3ab2d7 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java
@@ -11,7 +11,10 @@ import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mybatis.spring.SqlSessionTemplate;
@@ -36,9 +39,12 @@ class ReturnSkuConversionPersistenceTest {
ReturnSkuConversionService service, target;
ReturnInventoryBalanceMapper balances;
DataSourceTransactionManager transactions;
+ ReturnSkuMySqlTestDatabase mysql;
- @BeforeEach void setUp() throws Exception {
- var ds = new DriverManagerDataSource("jdbc:h2:mem:sku-conversion-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
+ @BeforeEach void setUp(TestInfo info) throws Exception {
+ boolean mysqlMode = Boolean.getBoolean("returnSku.test.mysql");
+ var ds = mysqlMode ? (mysql = ReturnSkuMySqlTestDatabase.open(info.getDisplayName())).dataSource()
+ : new DriverManagerDataSource("jdbc:h2:mem:sku-conversion-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds); transactions = new DataSourceTransactionManager(ds);
for (var entity : List.of(ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
ReturnSkuConversion.class, ReturnInventoryOperation.class, RelabelItem.class)) {
@@ -46,10 +52,12 @@ class ReturnSkuConversionPersistenceTest {
.filter(f -> f.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class) == null
|| f.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class).exist())
.map(f -> f.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " "
- + (f.getType() == String.class ? "VARCHAR(8000)" : f.getType() == LocalDateTime.class ? "TIMESTAMP"
+ + (f.getType() == String.class ? (mysqlMode ? (f.getName().endsWith("Json") ? "TEXT" : "VARCHAR(128)") : "VARCHAR(8000)") : f.getType() == LocalDateTime.class ? "TIMESTAMP"
: f.getType() == java.math.BigDecimal.class ? "DECIMAL(20,6)" : "BIGINT")
+ (f.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(","));
- jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")");
+ jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")"
+ + (mysqlMode ? " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE="
+ + (entity == ReturnInventoryCartonItem.class ? "utf8mb4_0900_ai_ci" : "utf8mb4_unicode_ci") : ""));
}
jdbc.execute("CREATE UNIQUE INDEX balance_sku ON ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code)");
jdbc.execute("CREATE UNIQUE INDEX conversion_round ON ll_return_sku_conversion(item_id,round_key,old_sku_code,new_sku_code)");
@@ -74,6 +82,8 @@ class ReturnSkuConversionPersistenceTest {
service = (ReturnSkuConversionService) proxy.getProxy();
}
+ @AfterEach void cleanIsolatedMySql() throws Exception { if (mysql != null) mysql.close(); }
+
@ParameterizedTest @ValueSource(strings={"BOX", "PARCEL"})
void partialConversionPreservesPhysicalUnitAndLocksUntilConfirmation(String type) {
fixture(type); apply(1, 11, 8, "INITIAL");
@@ -82,15 +92,66 @@ class ReturnSkuConversionPersistenceTest {
assertEquals(2, count("SELECT sku_types FROM ll_return_inventory_carton WHERE id=101"));
assertEquals(12, balance("A", "total_quantity")); assertEquals(8, balance("B", "total_quantity"));
assertEquals("BOX".equals(type) ? 1 : 0, balance("B", "carton_quantity"));
- var locked = balances.page("C", "W", "B", 0, 20).get(0);
- assertEquals(0, locked.getAvailableQuantity()); assertEquals(8, locked.getRelabelLockedQuantity());
+ // H2 verifies the persisted lock source and transaction, not MySQL's COLLATE projection.
+ assertEquals("RELABEL_PENDING", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=101", String.class));
+ assertEquals(1, count("SELECT relabel_application_id FROM ll_return_inventory_carton WHERE id=101"));
+ assertEquals("APPLIED", jdbc.queryForObject("SELECT status FROM ll_return_sku_conversion", String.class));
assertEquals(8, count("SELECT SUM(quantity) FROM ll_return_packing_trace WHERE target_carton_mark='UNIT-1' AND sku_code='B'"));
service.confirm(app(1));
jdbc.update("UPDATE ll_return_inventory_carton SET status='AVAILABLE',relabel_application_id=NULL WHERE id=101");
- assertEquals(8, balances.page("C", "W", "B", 0, 20).get(0).getAvailableQuantity());
+ assertStock("B", 8, 8);
+ assertEquals(8, balance("B", "available_quantity"));
+ assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=101", String.class));
assertEquals("CONFIRMED", jdbc.queryForObject("SELECT status FROM ll_return_sku_conversion", String.class));
}
+ @ParameterizedTest @ValueSource(strings={"BOX", "PARCEL"})
+ @EnabledIfSystemProperty(named="returnSku.test.mysql", matches="true")
+ void mysqlPageLocksConvertedSkuUntilConfirmationWithoutChangingPhysicalUnit(String type) {
+ fixture(type);
+ var physical = jdbc.queryForMap("SELECT id,carton_mark,source_return_no,source_unit_type,total_quantity FROM ll_return_inventory_carton WHERE id=101");
+ apply(1,11,8,"INITIAL");
+ assertStock("A",12,0); assertStock("B",8,8);
+ assertEquals(12,balance("A","total_quantity")); assertEquals(8,balance("B","total_quantity"));
+ assertEquals("BOX".equals(type) ? 1 : 0,balance("B","carton_quantity"));
+ assertPageQuantity("B",0,8);
+ service.confirm(app(1));
+ jdbc.update("UPDATE ll_return_inventory_carton SET status='AVAILABLE',relabel_application_id=NULL WHERE id=101");
+ assertPageQuantity("B",8,0);
+ assertEquals(physical,jdbc.queryForMap("SELECT id,carton_mark,source_return_no,source_unit_type,total_quantity FROM ll_return_inventory_carton WHERE id=101"));
+ mysql.record("physicalUnit",type); mysql.record("oldSkuQuantity",12); mysql.record("newSkuQuantity",8);
+ mysql.record("beforeConfirmation",Map.of("available",0,"locked",8));
+ mysql.record("afterConfirmation",Map.of("available",8,"locked",0));
+ mysql.record("physicalUnitUnchanged",true);
+ }
+
+ @ParameterizedTest @ValueSource(strings={"cafe", "CAFÉ"})
+ @EnabledIfSystemProperty(named="returnSku.test.mysql", matches="true")
+ void mysqlPageCollatesCaseAndAccentsWithoutJoiningOtherCustomerWarehouseOrSku(String stockSku) {
+ fixture("BOX"); apply(1,11,8,"INITIAL");
+ jdbc.update("UPDATE ll_return_inventory_balance SET sku_code='Café' WHERE sku_code='B'");
+ jdbc.update("UPDATE ll_return_inventory_carton_item SET sku_code=? WHERE sku_code='B'",stockSku);
+ for (int id=401;id<=403;id++) {
+ String customer=id==401 ? "OTHER" : "C", warehouse=id==402 ? "OTHER" : "W", sku=id==403 ? "CAFETERIA" : "café";
+ jdbc.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,count_status,total_quantity,sku_types) VALUES(?,?,?,?,'RELABEL_PENDING','CONFIRMED',99,1)",id,customer,warehouse,"BOUNDARY-"+id);
+ jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity,relabelled_quantity,packing_reserved_quantity) VALUES(?,?,?,?,99,0,0)",id,id,customer,sku);
+ }
+ assertPageQuantity("CAFE",0,8);
+ jdbc.update("UPDATE ll_return_inventory_carton SET status='AVAILABLE'");
+ assertPageQuantity("café",8,0);
+ mysql.record("stockSku",stockSku); mysql.record("balanceSku","Café");
+ mysql.record("caseAndAccentJoin",true); mysql.record("foreignCustomerWarehouseAndDistinctSkuExcluded",true);
+ }
+
+ void assertPageQuantity(String sku,long available,long locked) {
+ var rows=balances.page("C","W",sku,0,20); assertEquals(1,rows.size());
+ assertEquals(available,rows.get(0).getAvailableQuantity());
+ assertEquals(locked,rows.get(0).getRelabelLockedQuantity());
+ var platform=balances.platformPage("C","W",sku,0,20); assertEquals(1,platform.size());
+ assertEquals(available,platform.get(0).getAvailableQuantity());
+ assertEquals(locked,platform.get(0).getRelabelLockedQuantity());
+ }
+
@Test void replayAndLaterPartialRoundDoNotConvertPriorPiecesTwice() {
fixture("BOX"); apply(1, 11, 8, "INITIAL"); apply(1, 11, 8, "INITIAL");
assertEquals(1, count("SELECT COUNT(*) FROM ll_return_sku_conversion")); assertStock("A", 12, 0);
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuMySqlTestDatabase.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuMySqlTestDatabase.java
new file mode 100644
index 000000000..2e59149e6
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuMySqlTestDatabase.java
@@ -0,0 +1,79 @@
+package com.baiyue.lastleg.returnreceipt.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import java.nio.file.*;
+import java.sql.*;
+import java.util.*;
+import javax.sql.DataSource;
+
+/** Opt-in TEST only. Each invocation owns a fresh, non-configurable schema; never connects to business schemas. */
+final class ReturnSkuMySqlTestDatabase implements AutoCloseable {
+ private final String schema="cc_sku_accept_"+UUID.randomUUID().toString().replace("-","");
+ private final Map evidence=new LinkedHashMap<>();
+ private DriverManagerDataSource administration;
+ private DataSource source;
+ private boolean created;
+
+ static ReturnSkuMySqlTestDatabase open(String name) throws Exception {
+ var database=new ReturnSkuMySqlTestDatabase();
+ database.evidence.put("test",name); database.evidence.put("schema",database.schema);
+ try { database.initialize(); return database; }
+ catch(Exception failure) { database.close(); throw failure; }
+ }
+
+ private void initialize() throws Exception {
+ if(!Boolean.getBoolean("returnSku.test.mysql") || !"test".equals(required("CHONGCHONG_ENV")))
+ throw new IllegalStateException("Explicit TEST MySQL opt-in required");
+ if(!schema.matches("cc_sku_accept_[a-f0-9]{32}")) throw new IllegalStateException("Unsafe isolated schema");
+ String host=required("CHONGCHONG_DB_HOST"),port=required("CHONGCHONG_DB_PORT");
+ if(!host.matches("[A-Za-z0-9.-]+") || !port.matches("[0-9]{1,5}")) throw new IllegalStateException("Invalid TEST connection settings");
+ String prefix="jdbc:mysql://"+host+":"+port+"/";
+ String options="?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&connectTimeout=10000&socketTimeout=15000";
+ String user=required("CHONGCHONG_DB_USERNAME"),password=required("CHONGCHONG_DB_PASSWORD");
+ administration=new DriverManagerDataSource(prefix+"information_schema"+options,user,password);
+ try(var connection=administration.getConnection();var statement=connection.createStatement()) {
+ if(!"information_schema".equals(connection.getCatalog()) || !"MySQL".equals(connection.getMetaData().getDatabaseProductName())
+ || connection.getMetaData().getDatabaseMajorVersion()!=8) throw new IllegalStateException("Expected TEST MySQL 8 administrative read catalog");
+ statement.execute("CREATE DATABASE `"+schema+"` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
+ created=true;
+ } catch(SQLException failure) { throw new IllegalStateException("TEST isolated schema creation failed; SQLState="+failure.getSQLState()); }
+ source=new DriverManagerDataSource(prefix+schema+options,user,password) {
+ @Override public Connection getConnection() throws SQLException {
+ var connection=super.getConnection();
+ if(!created || !schema.equals(connection.getCatalog())) { connection.close(); throw new SQLException("Isolated schema boundary rejected"); }
+ return connection;
+ }
+ };
+ evidence.put("created",true); evidence.put("databaseProduct","MySQL 8");
+ }
+
+ DataSource dataSource() { return source; }
+ void record(String key,Object value) { evidence.put(key,value); }
+
+ @Override public void close() throws Exception {
+ try {
+ if(created) {
+ if(!schema.matches("cc_sku_accept_[a-f0-9]{32}")) throw new IllegalStateException("Unsafe cleanup schema");
+ try(var connection=administration.getConnection();var statement=connection.createStatement()) {
+ if(!"information_schema".equals(connection.getCatalog())) throw new IllegalStateException("Unexpected cleanup catalog");
+ statement.execute("DROP DATABASE `"+schema+"`"); created=false;
+ try(var result=statement.executeQuery("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='"+schema+"'")) {
+ result.next(); if(result.getInt(1)!=0) throw new IllegalStateException("Isolated schema cleanup not confirmed");
+ }
+ evidence.put("cleaned",true);
+ } catch(SQLException failure) { throw new IllegalStateException("TEST isolated schema cleanup failed; SQLState="+failure.getSQLState()); }
+ }
+ } finally {
+ var root=Path.of(System.getProperty("maven.multiModuleProjectDirectory","."));
+ var directory=root.resolve("reports/return-sku-mysql-acceptance"); Files.createDirectories(directory);
+ new ObjectMapper().writerWithDefaultPrettyPrinter().writeValue(directory.resolve(schema+".json").toFile(),evidence);
+ }
+ }
+
+ private static String required(String name) {
+ String value=System.getenv(name);
+ if(value==null || value.isBlank()) throw new IllegalStateException("Missing TEST environment variable "+name);
+ return value;
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java
index b4082dc01..1c1237bdf 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java
@@ -56,6 +56,22 @@ class Phase5OverseasUpgradeSqlContractTest {
assertThat(overseas).doesNotContain("\nUPDATE ", "\nDELETE ", "ALTER TABLE ll_");
}
+ @Test
+ void transferFinalMeasurementColumnsAreCreatedBeforeAnyBusinessWrite() throws Exception {
+ for (String path : List.of("lastleg-service/src/main/resources/db/lastleg.sql",
+ "deploy/sql/海外区域统一增量-MySQL8.sql")) {
+ String sql = repositoryFile(path);
+ String carton = createStatement(sql, "ll_warehouse_transfer_carton");
+ for (String column : List.of("customer_carton_mark", "length_cm", "width_cm", "height_cm",
+ "gross_weight_kg", "counted_quantity", "count_idempotency_key", "count_request_hash",
+ "counted_by", "counted_at", "discrepancy_reason", "transfer_pallet_id", "sku_items_json")) {
+ assertThat(carton).as(path + ":" + column).contains(column);
+ }
+ assertThat(createStatement(sql, "ll_warehouse_transfer_item")).contains("declared_quantity");
+ assertThat(createStatement(sql, "ll_cargo_discard_item")).contains("idx_ll_cargo_discard_return_carton");
+ }
+ }
+
@Test
void overseasFixedEntryAndModuleBaselineBothDefineCompletePhase5Tables() throws Exception {
String baseline = repositoryFile("lastleg-service/src/main/resources/db/lastleg.sql");
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java
index bc57114ec..817b624e7 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java
@@ -26,7 +26,7 @@ class SchemaChineseCommentContractTest {
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
private static final Pattern TABLE = Pattern.compile(
"(?is)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?(ll_[a-z0-9_]+)"
- + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB[^;]*?COMMENT\\s*=\\s*'([^']*)'\\s*;");
+ + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB([^;]*);");
@Test
void allLastlegTablesAndColumnsHaveChineseComments() throws Exception {
@@ -104,7 +104,8 @@ class SchemaChineseCommentContractTest {
Matcher matcher = TABLE.matcher(sql);
while (matcher.find()) {
String table = matcher.group(1);
- assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
+ assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s*=\\s*'[^']*[\\u4e00-\\u9fff][^']*'")
+ .matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
List columns = splitTopLevel(matcher.group(2));
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
List columnNames = new ArrayList<>();
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java
index 39cf2973d..7b9e4e314 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java
@@ -6,35 +6,62 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.Set;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
class SelfoperatedAnalyticsSchemaContractTest {
private static final Pattern TABLE = Pattern.compile(
- "(?s)CREATE TABLE (ll_selfop_\\w+) \\(.*?\\) ENGINE=InnoDB[^;]+;");
+ "(?s)CREATE TABLE (?:IF NOT EXISTS )?(ll_selfop_\\w+) \\(.*?\\) ENGINE=InnoDB[^;]+;");
+ private static final Set EXPECTED_TABLES = Set.of(
+ "ll_selfop_outbound_discovery_scope", "ll_selfop_sync_scope", "ll_selfop_sync_task",
+ "ll_selfop_sync_run", "ll_selfop_sync_stage", "ll_selfop_outbound_order",
+ "ll_selfop_outbound_item", "ll_selfop_inventory_snapshot", "ll_selfop_sku_daily_sales",
+ "ll_selfop_warehouse_daily_sales", "ll_selfop_sku_profile", "ll_selfop_owner",
+ "ll_selfop_product", "ll_selfop_inventory_flow", "ll_selfop_inventory_day_version",
+ "ll_selfop_inventory_daily");
private static Map tables(String sql) {
var result = new LinkedHashMap();
- var matcher = TABLE.matcher(sql);
+ var matcher = TABLE.matcher(sql.replace("\r\n", "\n"));
while (matcher.find()) {
- assertNull(result.put(matcher.group(1), matcher.group()), "duplicate table definition");
+ assertNull(result.put(matcher.group(1), matcher.group().replaceFirst("CREATE TABLE IF NOT EXISTS", "CREATE TABLE")),
+ "duplicate table definition");
}
return result;
}
@Test
- void sourceAndEmptyDatabaseBaselineContainIdenticalElevenIndependentTables() throws Exception {
+ void sourceAndEmptyDatabaseBaselineContainAllCurrentIndependentTables() throws Exception {
var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
- assertEquals(11, source.size());
+ assertEquals(EXPECTED_TABLES, source.keySet());
assertEquals(source, deployment);
for (var entry : source.entrySet()) {
var ddl = entry.getValue();
- assertTrue(ddl.contains("COLLATE=utf8mb4_bin"), entry.getKey());
- assertTrue(ddl.contains("id BIGINT NOT NULL"));
- assertTrue(ddl.contains("created_at DATETIME(3) NOT NULL"));
- assertTrue(ddl.contains("updated_at DATETIME(3) NOT NULL"));
+ boolean master = Set.of("ll_selfop_owner", "ll_selfop_product").contains(entry.getKey());
+ boolean history = Set.of("ll_selfop_inventory_flow", "ll_selfop_inventory_day_version",
+ "ll_selfop_inventory_daily").contains(entry.getKey());
+ if (master) {
+ assertTrue(ddl.contains("source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL"));
+ assertTrue(ddl.contains("customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL"));
+ assertTrue(ddl.contains("observed_at_utc DATETIME(6) NOT NULL"));
+ } else {
+ assertTrue(ddl.contains("COLLATE=utf8mb4_bin"), entry.getKey());
+ }
+ if (!master && !history) {
+ assertTrue(ddl.contains("created_at DATETIME(3) NOT NULL"), entry.getKey());
+ assertTrue(ddl.contains("updated_at DATETIME(3) NOT NULL"), entry.getKey());
+ }
+ assertTrue(ddl.contains("PRIMARY KEY ("), entry.getKey());
+ if (entry.getKey().equals("ll_selfop_inventory_daily")) {
+ assertTrue(ddl.contains("PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality)"));
+ } else if (entry.getKey().equals("ll_selfop_warehouse_daily_sales")) {
+ assertTrue(ddl.contains("PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date)"));
+ } else {
+ assertTrue(ddl.contains("\n id BIGINT NOT NULL"), entry.getKey());
+ }
assertFalse(ddl.contains("AUTO_INCREMENT"));
assertFalse(ddl.contains("tenant_id"));
assertFalse(ddl.contains("FOREIGN KEY"));
@@ -107,21 +134,34 @@ class SelfoperatedAnalyticsSchemaContractTest {
}
@Test
- void readAndSyncPermissionsAreIndependentWithThreeAuthorizedAdminMenus() throws Exception {
+ void readSyncAndExportPermissionsAreIndependentWithFourAuthorizedAdminMenus() throws Exception {
var seed = Files.readString(Path.of("../iam/src/main/resources/db/selfoperated-analytics-permissions.sql"));
var unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
- assertTrue(unified.contains(seed.strip()));
+ // Compare executable seed statements; explanatory comments and line endings are not SQL facts.
+ String seedSql = seed.replace("\r\n", "\n").replaceAll("(?m)^\\s*--[^\n]*", "");
+ String unifiedSql = unified.replace("\r\n", "\n").replaceAll("(?m)^\\s*--[^\n]*", "");
+ int comparedStatements = 0;
+ for (String statement : seedSql.split(";")) {
+ if (statement.strip().startsWith("INSERT INTO")) {
+ assertTrue(unifiedSql.contains(statement.strip() + ";"), statement);
+ comparedStatements++;
+ }
+ }
+ assertEquals(2, comparedStatements, "permission and role grant INSERT statements must both be checked");
assertTrue(seed.contains("platform:self-operated-analytics:list"));
assertTrue(seed.contains("platform:self-operated-analytics:sync"));
- for (var route : new String[]{"/self-operated/sales", "/self-operated/inventory", "/self-operated/collection"}) {
+ assertTrue(seed.contains("platform:self-operated-analytics:export"));
+ for (var route : new String[]{"/self-operated/overview", "/self-operated/sales", "/self-operated/inventory", "/self-operated/collection"}) {
assertTrue(seed.contains(route));
}
assertTrue(seed.contains("'API',5118100,'platform:self-operated-analytics:list'"));
assertTrue(seed.contains("'API',5118130,'platform:self-operated-analytics:sync'"));
+ assertTrue(seed.contains("'API',5118120,'platform:self-operated-analytics:export'"));
assertFalse(seed.contains("'MERCHANT'"));
assertFalse(seed.contains("INSERT IGNORE"));
assertFalse(seed.contains("ON DUPLICATE KEY"));
assertTrue(seed.contains("(4000001,5118000,0,"));
assertTrue(seed.contains("(4000001,5118001,0,"));
+ assertTrue(seed.contains("(4000001,5118002,0,"));
}
}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java
index d40346063..b7a262373 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java
@@ -217,6 +217,7 @@ class WarehouseTransferRelabelPersistenceTest {
ReflectionTestUtils.setField(service,"pdaTasks",tasks);
ReflectionTestUtils.setField(service,"returnOrders",mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper.class));
ReflectionTestUtils.setField(service,"skuConversions",mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
+ ReflectionTestUtils.setField(service,"bindingGuard",mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
ReflectionTestUtils.setField(service,"histories",mock(com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper.class));
ReflectionTestUtils.setField(service,"orderHistories",mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class));
var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var order=new com.baiyue.lastleg.entity.LastlegOrder();
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 7719f31ea..201bad69f 100644
--- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs
+++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs
@@ -8,8 +8,16 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_finance']
const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse']
+test('种子解析覆盖多行多值、函数括号及带逗号和引号的文本', () => {
+ const sql = "INSERT INTO sample (id,label,stamp,note) VALUES\r\n (1,'甲,乙',CURRENT_TIMESTAMP(3),NULL),\r\n (2,'客户''名称',CURRENT_TIMESTAMP(3),'已确认');\r\n"
+ assert.deepEqual(seedRows(sql, 'sample'), [
+ { id: '1', label: '甲,乙', stamp: 'CURRENT_TIMESTAMP(3)', note: null },
+ { id: '2', label: "客户'名称", stamp: 'CURRENT_TIMESTAMP(3)', note: '已确认' },
+ ])
+})
+
test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => {
- for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 159]]) {
+ for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 208]]) {
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
const body = sql.replace(/^--.*$/gm, '')
@@ -39,7 +47,10 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
- assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|159) AS initialized_tables;$/)
+ const end = body.trim().match(/SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (\d+) AS initialized_tables;$/)
+ assert.ok(end, 'only the final statement may announce successful initialization')
+ assert.equal(Number(end[2]), tableDefinitions(sql).size)
+ assert.equal(end[1], sql === cn ? 'CN' : 'OVERSEAS')
}
})
@@ -100,10 +111,11 @@ test('公司车辆费用隔离于客户账务并约束实际付款金额', () =>
test('退货出库不可变流水限制重复扣减并保存数量守恒快照', () => {
const journal = table(overseas, 'baiyue_lastleg', 'll_return_inventory_outbound_movement')
assert.match(journal, /UNIQUE KEY uk_ll_return_outbound_pick_sku\(outbound_pick_id,sku_code\)/i)
- assert.match(journal, /UNIQUE KEY uk_ll_return_outbound_carton_sku\(customer_id,carton_id,sku_code\)/i)
+ assert.match(journal, /KEY idx_ll_return_outbound_carton_sku\(customer_id,carton_id,sku_code\)/i)
+ assert.doesNotMatch(journal, /UNIQUE KEY uk_ll_return_outbound_carton_sku/i)
assert.match(journal, /CHECK\(quantity>0 AND total_after>=0 AND available_after>=0/i)
assert.match(journal, /total_before-total_after=quantity AND available_before-available_after=quantity/i)
- assert.match(journal, /cartons_before-cartons_after=1 AND total_after>=available_after/i)
+ assert.match(journal, /cartons_before-cartons_after IN \(0,1\) AND total_after>=available_after/i)
})
test('平台管理员由外部bcrypt引导,强制首登改密且不创建客户', () => {
@@ -121,13 +133,14 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
})
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => {
- for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 419, iam_role_permission: 600,
+ for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 433, iam_role_permission: 614,
iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4,
fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4,
file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name)
for (const name of ['wh_pallet', 'wh_location', 'wh_partner', 'wh_partner_type', 'wh_express_company',
'ord_order', 'fl_inbound_order', 'fin_fee_source']) assert.equal(seedRows(cn, name).length, 0, name)
- assert.deepEqual([...new Set([...overseas.matchAll(/^INSERT INTO (\w+)/gm)].map(m => m[1]))], [])
+ assert.deepEqual([...new Set([...overseas.matchAll(/^INSERT INTO (\w+)/gm)].map(m => m[1]))], ['ll_appointment_work_guard'])
+ assert.deepEqual(seedRows(overseas, 'll_appointment_work_guard'), [{ id: '1', initialized: 'FALSE' }])
assert.ok(seedRows(cn, 'wh_warehouse').every(r => r.tenant_id === '0'))
assert.ok(seedRows(cn, 'wh_service_capability').every(r => r.service_type === 'RECEIVING' || r.capability_code === 'RECEIVING' || Object.values(r).includes('RECEIVING')))
})
@@ -135,6 +148,10 @@ test('必要系统种子完整保留,但不写入示例资源与业务事实',
test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完整', () => {
const roles = seedRows(cn, 'iam_role'), permissions = seedRows(cn, 'iam_permission')
const roleIds = new Set(roles.map(r => r.id)), permissionIds = new Set(permissions.map(r => r.id))
+ assert.equal(permissionIds.size, permissions.length, 'duplicate permission ID')
+ const grants = seedRows(cn, 'iam_role_permission')
+ assert.equal(new Set(grants.map(r => `${r.role_id}:${r.permission_id}:${r.tenant_id}`)).size, grants.length,
+ 'duplicate role permission grant')
assert.equal(new Set(permissions.map(r => r.permission_code)).size, permissions.length)
for (const row of seedRows(cn, 'iam_role_permission')) {
assert.ok(roleIds.has(row.role_id), 'missing role ' + row.role_id)
diff --git a/scripts/sql-baseline-contract-helpers.mjs b/scripts/sql-baseline-contract-helpers.mjs
index 92be7086a..df553e04b 100644
--- a/scripts/sql-baseline-contract-helpers.mjs
+++ b/scripts/sql-baseline-contract-helpers.mjs
@@ -49,12 +49,15 @@ function splitValues(value) {
}
export function seedRows(sql, name) {
- const pattern = new RegExp(`^INSERT INTO ${name} \\(([^\\n]+?)\\) VALUES \\(([^\\n]+)\\);$`, 'gm')
- return [...sql.matchAll(pattern)].map(match => {
+ const pattern = new RegExp(`^INSERT INTO ${name} \\(([^\\n]+?)\\) VALUES\\s*([\\s\\S]*?);$`, 'gm')
+ return [...sql.replaceAll('\r\n', '\n').matchAll(pattern)].flatMap(match => {
const keys = match[1].split(',').map(key => key.trim())
- const values = splitValues(match[2])
- if (keys.length !== values.length) throw new Error(`Invalid seed columns: ${name}`)
- return Object.fromEntries(keys.map((key, i) => [key, values[i]]))
+ return splitValues(match[2]).map(row => {
+ if (!row.startsWith('(') || !row.endsWith(')')) throw new Error(`Invalid seed row: ${name}`)
+ const values = splitValues(row.slice(1, -1))
+ if (keys.length !== values.length) throw new Error(`Invalid seed columns: ${name}`)
+ return Object.fromEntries(keys.map((key, i) => [key, values[i]]))
+ })
})
}