fix(lastleg): route all relabel pickups directly to outbound and verify final schemas
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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 '真实操作人员标识',
|
||||
|
||||
@@ -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 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
|
||||
|
||||
+2
-2
@@ -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' " +
|
||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||
@@ -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 " +
|
||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
|
||||
+5
-4
@@ -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<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
|
||||
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
|
||||
returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList();
|
||||
|
||||
+10
-2
@@ -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) {
|
||||
|
||||
+15
-5
@@ -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<RelabelItem> 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<Long, List<RelabelItem>> 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<RelabelItem> group = entry.getValue();
|
||||
|
||||
+54
-8
@@ -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<String, Set<String>> bindings = new HashMap<>();
|
||||
String currentRound = null;
|
||||
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper<PdaRelabelHistory>()
|
||||
.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<String, Long>();
|
||||
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<String, Long>();
|
||||
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<String, Long>();
|
||||
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<RelabelItem> selectedItems,
|
||||
Map<String, Long> 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<String, Long> 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); }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+6
-1
@@ -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)"));
|
||||
|
||||
+26
@@ -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("<if test='customerId"));
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:relabel-mode-" + method + ";MODE=MySQL")) {
|
||||
var statement = connection.createStatement();
|
||||
statement.execute("CREATE TABLE ll_relabel_application(delivery_method VARCHAR,status VARCHAR,fulfillment_status VARCHAR)");
|
||||
statement.execute("INSERT INTO ll_relabel_application VALUES "
|
||||
+ "('TRUCK','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
|
||||
+ "('PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
|
||||
+ "('AMAZON_PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
|
||||
+ "('CUSTOMER_PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
|
||||
+ "('UNKNOWN','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
|
||||
+ "(NULL,'READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
|
||||
+ "('EXPRESS','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH')");
|
||||
// The return query also requires physical return-carton availability.
|
||||
eligibility = eligibility.replace("AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL", "");
|
||||
try (var rows = statement.executeQuery("SELECT delivery_method FROM ll_relabel_application a " + eligibility)) {
|
||||
assertTrue(rows.next()); assertEquals("TRUCK", rows.getString(1)); assertFalse(rows.next());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedAppointmentUsesOriginalCustomerOrderAndDestinationAndNeverInventsLoadingTime() throws Exception {
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-confirmed-appointment;MODE=MySQL")) {
|
||||
|
||||
+19
@@ -51,6 +51,25 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DispatchServiceTest {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.NullAndEmptySource
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "UNKNOWN"})
|
||||
void directRelabelCannotResolveDispatchScopeOrBypassCandidateFiltering(String deliveryMethod) {
|
||||
var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelApplications", relabels);
|
||||
var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication();
|
||||
application.setCustomerId("100"); application.setStatus("READY_FOR_OUTBOUND");
|
||||
application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); application.setDeliveryMethod(deliveryMethod);
|
||||
when(relabels.findAnyByRelabelNo("RL-1")).thenReturn(application);
|
||||
when(relabels.lockNo("100", "RL-1")).thenReturn(application);
|
||||
assertThrows(ResponseStatusException.class, () -> 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;
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
+96
-3
@@ -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<com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion>();
|
||||
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) {
|
||||
|
||||
+107
-4
@@ -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));
|
||||
|
||||
+6
-2
@@ -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));
|
||||
}
|
||||
|
||||
+68
-7
@@ -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);
|
||||
|
||||
+79
@@ -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<String,Object> 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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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");
|
||||
|
||||
+3
-2
@@ -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<String> columns = splitTopLevel(matcher.group(2));
|
||||
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
|
||||
List<String> columnNames = new ArrayList<>();
|
||||
|
||||
+52
-12
@@ -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<String> 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<String, String> tables(String sql) {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
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,"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]]))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user