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]])) + }) }) }