fix(lastleg): restore fulfillment safeguards and reconcile return regressions

This commit is contained in:
2026-10-08 18:08:42 +08:00
parent c1ddb658bd
commit 3a9ee57e53
12 changed files with 409 additions and 770 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
| 区域 | 完整入口 | 服务 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` | 157 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 159 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
+56 -657
View File
@@ -379,7 +379,6 @@ CREATE TABLE ll_dispatch_item (
source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
KEY idx_ll_dispatch_item_order (customer_id,order_id),
@@ -438,7 +437,6 @@ CREATE TABLE ll_dispatch_pallet_lock (
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key),
KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status),
@@ -465,12 +463,13 @@ CREATE TABLE ll_dispatch_pda_pick (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
task_id bigint NOT NULL COMMENT 'PDA任务ID',
dispatch_item_id bigint NOT NULL COMMENT '派车明细ID',
inventory_position_id bigint NOT NULL COMMENT '库存仓位记录ID',
inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID',
packages bigint NOT NULL COMMENT '本次操作箱数',
product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
picked_at datetime(3) NOT NULL COMMENT '拣货时间',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
return_carton_id BIGINT NULL COMMENT '独立退货库存箱ID,与普通仓位互斥',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key),
KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id)
@@ -510,10 +509,9 @@ CREATE TABLE ll_dispatch_pda_pick_order (
product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥',
KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id),
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)),
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1),
PRIMARY KEY (id),
KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id),
KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id)
@@ -2303,7 +2301,7 @@ CREATE TABLE ll_relabel_status_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标状态变更历史';
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
CREATE TABLE ll_return_inventory_outbound_movement (
id BIGINT NOT NULL COMMENT '退货出库SKU流水ID',
outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键',
@@ -2375,10 +2373,13 @@ CREATE TABLE ll_return_inventory_carton (
merged_at datetime(3) DEFAULT NULL COMMENT '合箱完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark),
UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id),
KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at),
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING')) and (sku_types > 0) and (total_quantity > 0)) or ((status in (_utf8mb4'MERGED',_utf8mb4'DISCARDED')) and (sku_types = 0) and (total_quantity = 0)))),
CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING',_utf8mb4'DISCARDED')))
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)),
CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')),
source_unit_code VARCHAR(128) NULL COMMENT '来源单元业务码',
location_code VARCHAR(128) NULL COMMENT '退货箱当前库位',
putaway_at DATETIME(3) NULL COMMENT '退货箱上架时间',
source_unit_type VARCHAR(32) NULL COMMENT 'PALLET或BOX实物箱 PARCEL散SKU'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛';
CREATE TABLE ll_return_inventory_carton_item (
@@ -2391,7 +2392,8 @@ CREATE TABLE ll_return_inventory_carton_item (
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0))
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)),
packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
CREATE TABLE ll_return_inventory_operation (
@@ -2405,7 +2407,8 @@ CREATE TABLE ll_return_inventory_operation (
updated_at datetime(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_operation_operator_key (operator_id,idempotency_key),
KEY idx_ll_return_inventory_operation_result (result_carton_id)
KEY idx_ll_return_inventory_operation_result (result_carton_id),
result_json JSON NULL COMMENT '幂等操作不可变返回快照'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存PDA合箱幂等操作审计';
CREATE TABLE ll_return_order (
@@ -2481,7 +2484,14 @@ CREATE TABLE ll_return_receipt (
UNIQUE KEY uk_ll_return_receipt_operator_key (operator_id,idempotency_key),
KEY idx_ll_return_receipt_order (return_order_id,signed_at),
CONSTRAINT chk_ll_return_receipt_bound CHECK ((bound_by in (_utf8mb4'RETURN_NO',_utf8mb4'SKU'))),
CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0))
CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0)),
unit_type VARCHAR(16) NULL COMMENT '签收类型 PALLET/BOX/PARCEL',
unit_code VARCHAR(128) NULL COMMENT '签收单元业务码',
total_box_count BIGINT NULL COMMENT '签收SKU箱数汇总',
length DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
width DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
height DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
weight DECIMAL(12,3) NULL COMMENT '实测重量kg'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实';
CREATE TABLE ll_return_receipt_batch (
@@ -2510,7 +2520,8 @@ CREATE TABLE ll_return_receipt_item (
PRIMARY KEY (id),
KEY idx_ll_return_receipt_item_receipt (receipt_id),
KEY idx_ll_return_receipt_item_order_item (return_order_item_id),
CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0))
CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)),
box_count BIGINT NULL COMMENT 'SKU签收箱数'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细';
@@ -3352,6 +3363,36 @@ CREATE TABLE ll_warehouse_transfer_source_order (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓来源历史订单绑定';
-- 服务 schema:baiyue_lastlag_warehouse
CREATE TABLE ll_return_packing_plan (
id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细',
mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消',
remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no),
UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key),
KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at),
source_items_json JSON NULL COMMENT '精确来源箱和SKU锁定数量',
result_marks_json JSON NULL COMMENT '完成生成或并入的目标箱唛',
request_hash VARCHAR(64) NULL COMMENT '创建载荷摘要',
completion_hash VARCHAR(64) NULL COMMENT '完成载荷摘要',
result_labels_json JSON NULL COMMENT '计划完工标签快照'
) ENGINE=InnoDB COMMENT='退货合箱计划';
CREATE TABLE ll_return_packing_trace (
id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键',
operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照',
target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号',
sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数',
created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id),
KEY idx_return_packing_trace_order(customer_id,return_no,created_at),
KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code),
KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code),
CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯';
CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_lastlag_warehouse;
@@ -3542,646 +3583,4 @@ CREATE TABLE event_delivery_attempt (
KEY idx_delivery_attempt_outcome (outcome,started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 157 AS initialized_tables;
-- 2026-10-08 退货合箱精确预占、来源追溯与箱级上架;MySQL 8,可重复执行。
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
USE baiyue_lastleg;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收类型 PALLET/BOX/PARCEL'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收单元业务码'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='total_box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''签收SKU箱数汇总'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='width'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='height'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='weight'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''实测重量kg'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt_item' AND column_name='box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''SKU签收箱数'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源单元业务码'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
CREATE TABLE IF NOT EXISTS ll_return_packing_plan (
id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细',
mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消',
remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no),
UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key),
KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at)
) ENGINE=InnoDB COMMENT='退货合箱计划';
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='packing_reserved_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''合箱计划预占件数'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='source_items_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN source_items_json JSON NULL COMMENT ''精确来源箱和SKU锁定数量'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_marks_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_marks_json JSON NULL COMMENT ''完成生成或并入的目标箱唛'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN request_hash VARCHAR(64) NULL COMMENT ''创建载荷摘要'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='completion_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN completion_hash VARCHAR(64) NULL COMMENT ''完成载荷摘要'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='location_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN location_code VARCHAR(128) NULL COMMENT ''退货箱当前库位'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='putaway_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN putaway_at DATETIME(3) NULL COMMENT ''退货箱上架时间'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(32) NULL COMMENT ''PALLET或BOX实物箱 PARCEL散SKU'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED'));
CREATE TABLE IF NOT EXISTS ll_return_packing_trace (
id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键',
operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照',
target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号',
sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数',
created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id),
KEY idx_return_packing_trace_order(customer_id,return_no,created_at),
KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code),
KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code),
CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯';
ALTER TABLE ll_return_packing_trace MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码';
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_operation' AND column_name='result_json'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_operation ADD COLUMN result_json JSON NULL COMMENT ''幂等操作不可变返回快照'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_labels_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_labels_json JSON NULL COMMENT ''计划完工标签快照'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
-- Existing outbound tables need this field even when created before the fee snapshot baseline.
USE baiyue_lastleg;
SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='fee_coverage_json'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN fee_coverage_json JSON NULL COMMENT ''发运原费用及服务覆盖确认快照''');
PREPARE return_outbound_stmt FROM @return_outbound_ddl;
EXECUTE return_outbound_stmt;
DEALLOCATE PREPARE return_outbound_stmt;
-- 原生退货换标排车:保留独立退货箱来源,不伪造普通库存仓位。
USE baiyue_lastleg;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='return_carton_id')=0,
'ALTER TABLE ll_dispatch_pda_pick ADD COLUMN return_carton_id BIGINT NULL COMMENT ''独立退货库存箱ID,与普通仓位互斥''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID';
ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN receipt_id BIGINT NULL COMMENT '原入仓实收来源,与退回来源或原生退货箱互斥';
-- 真实退回凭据与尾程订单客户键使用同一排序规则,避免相关库存子查询冲突。
SET @return_customer_collation = (SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_id');
SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''客户数据归属''');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN source_outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''原发运出库业务编号''');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_carton_id')=0,
'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_carton_id BIGINT NULL COMMENT ''原生退货箱来源ID,与实收来源互斥''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND constraint_name='chk_dispatch_pick_receipt_source')>0,
'ALTER TABLE ll_dispatch_pda_pick_order DROP CHECK chk_dispatch_pick_receipt_source', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
ALTER TABLE ll_dispatch_pda_pick_order ADD CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1);
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='signed_packages')=0,
'ALTER TABLE ll_delivery_order ADD COLUMN signed_packages INT NULL COMMENT ''保存签收资料时冻结的实际签收箱数,不含此前已退回量''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
CREATE TABLE IF NOT EXISTS event_delivery_attempt (
attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键',
service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务',
event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型',
entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键',
event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空',
target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务',
started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间',
finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间',
duration_ms BIGINT NULL COMMENT '尝试耗时毫秒',
outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED',
error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌',
next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间',
PRIMARY KEY (attempt_id),
KEY idx_delivery_attempt_entry (service_name,entry_id,started_at),
KEY idx_delivery_attempt_outcome (outcome,started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
-- 补齐排车实体引用的真实退回来源字段;原生退货箱单独使用 return_carton_id。
USE baiyue_lastleg;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_item ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
-- 退货换标排车使用正式车次域,补齐旧本地库缺少的车队结构。
-- MySQL 8;显式 baiyue_lastleg;不写业务数据,可重复执行。
USE baiyue_lastleg;
USE baiyue_iam;
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted, created_at, updated_at)
VALUES
(5107300,0,'PLATFORM','MENU',0,'platform:overseas-master-data:driver:list','司机管理','/overseas-master-data/drivers','GET',930,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107301,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:create','新增司机',NULL,'POST',931,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107302,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:edit','编辑与重提司机档案',NULL,'PUT',932,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-master-data:driver:list',
'platform:overseas-master-data:driver:create',
'platform:overseas-master-data:driver:edit',
'platform:overseas-master-data:driver:upload',
'platform:overseas-master-data:driver:review',
'platform:fleet:trip:list',
'platform:fleet:trip:create',
'platform:fleet:trip:assign',
'platform:fleet:trip:edit',
'platform:fleet:trip:dispatch-review',
'platform:fleet:trip:loading-review')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
USE baiyue_lastleg;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_pallet_spaces')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_pallet_spaces INT NULL COMMENT ''最大标准托位,未知时为空''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_load_kg')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_load_kg DECIMAL(12,3) NULL COMMENT ''最大载重,千克''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_volume_cbm')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_volume_cbm DECIMAL(12,3) NULL COMMENT ''可用容积,立方米''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
CREATE TABLE IF NOT EXISTS ll_fleet_driver (
id BIGINT NOT NULL COMMENT '内部主键',
driver_no VARCHAR(32) NOT NULL COMMENT '司机业务编号',
warehouse_id BIGINT NULL COMMENT '所属公共仓库引用',
warehouse_code VARCHAR(64) NULL COMMENT '所属仓库编码快照',
warehouse_name VARCHAR(256) NULL COMMENT '所属仓库名称快照',
vehicle_models VARCHAR(128) NULL COMMENT '可驾驶车型描述',
languages_and_area VARCHAR(256) NULL COMMENT '语言及服务区域',
medical_expires_on DATE NULL COMMENT '体检到期日,当前不作为必需资质',
training_expires_on DATE NULL COMMENT '培训安全到期日,当前不作为必需资质',
insurance_expires_on DATE NULL COMMENT '保险到期日,当前不作为必需资质',
name VARCHAR(128) NOT NULL COMMENT '姓名',
phone VARCHAR(64) NOT NULL COMMENT '联系电话',
ownership VARCHAR(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派',
region VARCHAR(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回',
license_number VARCHAR(128) NULL COMMENT '驾驶证编号',
license_type VARCHAR(64) NULL COMMENT '驾驶证类型',
license_issued_on DATE NULL COMMENT '驾驶证签发日期',
license_expires_on DATE NULL COMMENT '驾驶证有效截止日',
license_zone VARCHAR(64) NOT NULL COMMENT '证照有效日期的IANA时区',
license_file_id BIGINT NULL COMMENT '平台驾驶证文件引用',
remark VARCHAR(1000) NULL COMMENT '备注',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_driver_no (driver_no),
KEY idx_fleet_driver_page (active,review_status,updated_at,id),
KEY idx_fleet_driver_expiry (license_expires_on),
CONSTRAINT chk_fleet_driver_ownership CHECK (ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT chk_fleet_driver_region CHECK (region IN ('DOMESTIC','OVERSEAS')),
CONSTRAINT chk_fleet_driver_review CHECK (review_status IN ('PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_fleet_driver_dates CHECK (license_issued_on IS NULL OR license_expires_on IS NULL OR license_issued_on <= license_expires_on)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台司机档案';
CREATE TABLE IF NOT EXISTS ll_fleet_driver_history (
id BIGINT NOT NULL COMMENT '内部主键',
driver_id BIGINT NOT NULL COMMENT '司机内部引用',
record_version BIGINT NOT NULL COMMENT '操作后的档案版本',
action VARCHAR(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作',
reason VARCHAR(1000) NULL COMMENT '操作原因或审核意见',
license_file_id BIGINT NULL COMMENT '当时驾驶证文件引用',
snapshot_json JSON NOT NULL COMMENT '操作后不可变档案快照',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version),
KEY idx_fleet_driver_history_file (driver_id,license_file_id),
CONSTRAINT fk_fleet_driver_history_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机档案及审核历史';
CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record (
id BIGINT NOT NULL COMMENT '记录内部主键',
record_no VARCHAR(32) NOT NULL COMMENT '车辆运维业务编号',
vehicle_id BIGINT NOT NULL COMMENT '所属车辆主键',
vehicle_plate VARCHAR(64) NOT NULL COMMENT '建档时车牌快照',
record_type VARCHAR(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常',
occurred_at DATETIME(3) NOT NULL COMMENT '记录发生时间(UTC)',
details_json JSON NOT NULL COMMENT '带明确单位的业务字段快照',
files_json JSON NOT NULL COMMENT '所属记录照片与上传幂等摘要',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原创建业务内容摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建操作人',
updated_by VARCHAR(64) NOT NULL COMMENT '最后操作人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间(UTC)',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间(UTC)',
PRIMARY KEY (id),
UNIQUE KEY uk_vehicle_record_no (record_no),
UNIQUE KEY uk_vehicle_record_creation (vehicle_id, created_by, idempotency_key),
KEY idx_vehicle_record_page (vehicle_id, record_type, occurred_at, id),
CONSTRAINT fk_vehicle_record_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id),
CONSTRAINT ck_vehicle_record_type CHECK (record_type IN ('MAINTENANCE','FUEL','REPAIR','BREAKDOWN','RESCUE','EXCEPTION'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录';
CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record_history (
id BIGINT NOT NULL COMMENT '历史内部主键',
record_id BIGINT NOT NULL COMMENT '运维记录主键',
record_version BIGINT NOT NULL COMMENT '对应业务版本',
action VARCHAR(32) NOT NULL COMMENT '操作类型',
reason VARCHAR(1000) DEFAULT NULL COMMENT '更正原因或附件名称',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间(UTC)',
snapshot_json JSON NOT NULL COMMENT '当时完整业务快照',
PRIMARY KEY (id),
UNIQUE KEY uk_vehicle_record_revision (record_id, record_version),
CONSTRAINT fk_vehicle_record_history FOREIGN KEY (record_id) REFERENCES ll_fleet_vehicle_record(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip (
id BIGINT NOT NULL COMMENT '内部主键',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '创建请求SHA256',
status VARCHAR(16) NOT NULL COMMENT '车次状态',
warehouse_id BIGINT NOT NULL COMMENT '起运公共仓库引用',
warehouse_code VARCHAR(64) NOT NULL COMMENT '起运仓编码快照',
warehouse_name VARCHAR(256) NOT NULL COMMENT '起运仓名称快照',
vehicle_id BIGINT NULL COMMENT '车辆档案引用',
vehicle_plate VARCHAR(64) NULL COMMENT '分配时车牌快照',
driver_id BIGINT NULL COMMENT '司机档案引用',
driver_name VARCHAR(128) NULL COMMENT '分配时司机姓名',
driver_phone VARCHAR(64) NULL COMMENT '分配时司机电话',
max_pallet_spaces INT NULL COMMENT '分配时托位容量快照',
max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克',
max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米',
planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC',
expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场',
departed_at DATETIME(3) NULL COMMENT '实际发车UTC',
completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC',
stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点',
planned_load_json JSON NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量',
load_json JSON NULL COMMENT '已确认实物装载及客户货物来源快照',
loading_review_json LONGTEXT NULL COMMENT '整车理货审核快照,空值为待审核',
departure_key VARCHAR(128) NULL COMMENT '整车发车幂等标识',
departure_hash CHAR(64) NULL COMMENT '整车发车请求摘要',
departure_result_json JSON NULL COMMENT '整车发车结果及各客户出库结果',
completion_json JSON NULL COMMENT '货物结案来源及幂等结果快照',
vehicle_release_json JSON NULL COMMENT '车辆任务结束地点状态与幂等记录',
driver_release_json JSON NULL COMMENT '司机任务结束地点与幂等记录',
remark VARCHAR(1000) NULL COMMENT '备注',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
updated_at DATETIME(3) NOT NULL COMMENT '更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_trip_no (trip_no),
UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key),
KEY idx_fleet_trip_page (warehouse_id,status,updated_at,id),
CONSTRAINT fk_fleet_trip_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id),
CONSTRAINT fk_fleet_trip_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id),
CONSTRAINT chk_fleet_trip_status CHECK (status IN ('PLANNED','DEPARTING','IN_TRANSIT','COMPLETED','CANCELLED')),
CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次';
CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer (
id BIGINT NOT NULL COMMENT '内部主键',
offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号',
trip_id BIGINT NOT NULL COMMENT '唯一所属车次',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭',
cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间',
contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人',
contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话',
remark VARCHAR(1000) NULL COMMENT '公开承运说明',
published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_no(offer_no),
UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id),
KEY idx_fleet_capacity_offer_status(status,cutoff_at,id),
CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存';
CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer_command (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域',
operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者',
action VARCHAR(32) NOT NULL COMMENT '业务命令类型',
scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域',
idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要',
result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放',
created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key),
CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果';
CREATE TABLE IF NOT EXISTS ll_fleet_external_booking (
id BIGINT NOT NULL COMMENT '内部主键',
booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号',
trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属',
customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照',
customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照',
status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消',
cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照',
quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存',
destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键',
destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称',
agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收',
idempotency_key VARCHAR(128) NOT NULL COMMENT '登记请求键',
request_hash CHAR(64) NOT NULL COMMENT '登记内容摘要',
confirm_key VARCHAR(128) NULL COMMENT '承运确认请求键',
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
incoming_hash CHAR(64) NULL COMMENT '来货登记摘要',
receipt_key VARCHAR(128) NULL COMMENT '实收确认请求键',
receipt_hash CHAR(64) NULL COMMENT '实收确认摘要',
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间',
expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本',
created_by VARCHAR(64) NOT NULL COMMENT '登记人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
KEY idx_fleet_booking_offer(offer_id,status,id),
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
KEY idx_fleet_booking_trip(trip_id,status,id),
UNIQUE KEY uk_fleet_booking_incoming(incoming_no),
KEY idx_fleet_booking_dispatch(dispatch_no),
CONSTRAINT fk_fleet_booking_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实';
CREATE TABLE IF NOT EXISTS ll_fleet_pool_order_link (
id BIGINT NOT NULL COMMENT '内部主键',
booking_id BIGINT NOT NULL COMMENT '唯一外接预订',
booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围',
business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型',
business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联',
business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号',
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库',
destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键',
delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓',
delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明',
incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空',
lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号',
fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成',
stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间',
actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空',
actual_package_count INT NULL COMMENT '实际箱数,未知为空',
actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本',
creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要',
created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_pool_order_booking(booking_id),
UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no),
UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id),
UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no),
UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id),
KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no),
KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no),
CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id),
CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')),
CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费';
CREATE TABLE IF NOT EXISTS ll_fleet_resource_assignment (
resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER',
resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用',
trip_id BIGINT NOT NULL COMMENT '占用车次',
trip_no VARCHAR(32) NOT NULL COMMENT '占用车次业务编号',
PRIMARY KEY (resource_kind,resource_id),
KEY idx_fleet_resource_trip (trip_id),
CONSTRAINT fk_fleet_resource_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_resource_kind CHECK (resource_kind IN ('VEHICLE','DRIVER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_dispatch (
dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联',
dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号',
customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属',
trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用',
trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号',
package_count INT NOT NULL COMMENT '关联时待发运箱数快照',
bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人',
bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间',
review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核',
PRIMARY KEY (dispatch_id),
UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no),
KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id),
CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_history (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '车次内部引用',
record_version BIGINT NOT NULL COMMENT '操作后版本',
action VARCHAR(32) NOT NULL COMMENT '车次操作',
reason VARCHAR(1000) NULL COMMENT '操作原因',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
snapshot_json JSON NOT NULL COMMENT '操作后不可变车次快照',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version),
CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_operations_record (
id BIGINT NOT NULL COMMENT '出车实测历史主键',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增',
details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照',
reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明',
operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识',
request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键',
request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要',
occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_operations_version (trip_no,record_version),
UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_handover (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号',
request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本',
handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间',
snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照',
vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果',
driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果',
created_by VARCHAR(64) NOT NULL COMMENT '记录操作人',
created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_handover_no (handover_no),
UNIQUE KEY uk_fleet_handover_request (trip_id, request_key),
UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version),
KEY idx_fleet_handover_page (trip_id, id),
CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接';
CREATE TABLE IF NOT EXISTS ll_fleet_finance_source (
id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键',
record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键',
source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增',
record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本',
operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识',
request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀',
event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID',
payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version),
UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key),
UNIQUE KEY uk_fleet_finance_source_event (event_id),
CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0)
) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定';
CREATE TABLE IF NOT EXISTS ll_fleet_quote (
id BIGINT PRIMARY KEY COMMENT '报价记录主键',
quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号',
kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售',
party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识',
party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照',
origin_city VARCHAR(128) NOT NULL COMMENT '发货城市',
destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码',
valid_from DATE NOT NULL COMMENT '报价生效日期',
valid_to DATE NOT NULL COMMENT '报价失效日期含当天',
status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态',
natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要',
details_json JSON NOT NULL COMMENT '完整报价明细快照',
record_version BIGINT NOT NULL COMMENT '报价记录版本',
create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要',
created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC',
UNIQUE KEY uk_fleet_quote_no (quote_no),
UNIQUE KEY uk_fleet_quote_natural (natural_key),
UNIQUE KEY uk_fleet_quote_create (created_by,create_key),
KEY idx_fleet_quote_search (kind,status,valid_from,valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价';
CREATE TABLE IF NOT EXISTS ll_fleet_quote_history (
id BIGINT PRIMARY KEY COMMENT '报价历史主键',
quote_id BIGINT NOT NULL COMMENT '报价记录标识',
record_version BIGINT NOT NULL COMMENT '报价记录版本',
action VARCHAR(24) NOT NULL COMMENT '报价操作类型',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
snapshot_json JSON NOT NULL COMMENT '报价完整版本快照',
UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '车次内部引用',
cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号',
category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER',
currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总',
amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款',
incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间',
remark VARCHAR(1000) NULL COMMENT '费用备注',
record_version BIGINT NOT NULL COMMENT '成本记录版本',
request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要',
created_by VARCHAR(64) NOT NULL COMMENT '登记人',
updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_trip_cost_no(cost_no),
UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key),
KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id),
CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0),
CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')),
CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost_history (
id BIGINT NOT NULL COMMENT '内部主键',
cost_id BIGINT NOT NULL COMMENT '成本记录内部引用',
record_version BIGINT NOT NULL COMMENT '成本更正后版本',
action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE',
reason VARCHAR(1000) NULL COMMENT '登记或更正说明',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version),
CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 159 AS initialized_tables;
@@ -1,5 +1,9 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -119,11 +123,31 @@ public class DeliveryTransferService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@Resource private com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper dispatchPickOrders;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelApplicationService relabelService;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private FleetBookingFreight bookingFreight;
@Resource private DeliveryFeeCoverageService feeCoverage;
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo));
if (dispatch == null) throw notFound("Dispatch order not found");
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
.toList());
}
@Transactional
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
@@ -136,6 +160,8 @@ public class DeliveryTransferService {
@Transactional
public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
if (request.feeCoverage() != null && !request.feeCoverage().isEmpty())
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认");
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
@@ -146,16 +172,20 @@ public class DeliveryTransferService {
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
DeliveryRequests.Transfer request) {
String customerId = dispatch.getCustomerId();
String requestHash = transferRequestHash(request);
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
return result(replay, requireDeliveries(customerId, replay.getId()), true);
return replayTransfer(replay, requestHash);
}
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (existing != null) return result(existing, requireDeliveries(customerId, existing.getId()), true);
if (existing != null) return replayTransfer(existing, requestHash);
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
throw conflict("Dispatch cannot be transferred to delivery in its current status");
}
fleetBindings.requireMatchingAssignment(dispatch.getId(),
new FleetDispatchGateway.Assignment(dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone()),
new FleetDispatchGateway.Assignment(request.vehiclePlate(), request.driverName(), request.driverPhone()));
dispatchItems.bindReceivedPallets(customerId, dispatch.getId());
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
@@ -163,7 +193,16 @@ public class DeliveryTransferService {
throw conflict("All dispatch pallets must be received before transfer to delivery");
}
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
validateTransferFees(request);
var freight = externalFreight(dispatch, items);
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
@@ -174,10 +213,15 @@ public class DeliveryTransferService {
outbound.setLxCodes(resolveLxCodes(customerId, items));
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size());
outbound.setGrossWeightKg(sum(items, DispatchItem::getGrossWeightKg));
outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg));
outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg));
outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm));
outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
if (coverage.evidence() != null) {
try { outbound.setFeeCoverageJson(objectMapper.writeValueAsString(coverage.evidence())); }
catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法保存", exception); }
}
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
try {
outboundOrders.insert(outbound);
@@ -186,11 +230,11 @@ public class DeliveryTransferService {
// the document created by the winning transaction rather than receive a false failure.
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
return replayTransfer(concurrent, requestHash);
}
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (concurrent != null) {
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
return replayTransfer(concurrent, requestHash);
}
throw conflict("Dispatch was concurrently transferred to delivery");
}
@@ -251,25 +295,57 @@ public class DeliveryTransferService {
delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
delivery.setPalletCount(groupItems.size());
delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm));
delivery.setTotalWeightKg(sum(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setTotalWeightKg(sumKnownPhysicalWeight(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now);
bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
deliveries.add(delivery);
}
for (var quote : freight.quotes()) {
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
+ quote.currency() + " " + quote.amount().toPlainString(), now);
// An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee.
if (quote.amount().signum() == 0) continue;
feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT",
new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(),
null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()),
"DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null,
null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(),
quote.quantityUnit(), quote.unitPrice(), null, null));
}
DeliveryOrder feeDelivery = deliveries.get(0);
LastlegOrder firstOrder = firstTailOrder(customerId, items);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
for (DeliveryRequests.FeeItem input : feeInputs) {
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(),
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
var uncovered = coverage.uncovered(feeCode);
var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet());
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
: feeDelivery;
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
}
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(),
firstOrder == null ? null : firstOrder.getId(), null,
firstOrder == null ? null : firstOrder.getInboundCode(), null,
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()),
input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()),
feeCode, input.feeName().trim(), input.direction(), input.supplierId(),
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
@@ -278,27 +354,45 @@ public class DeliveryTransferService {
return result(outbound, deliveries, false);
}
private void validateTransferFees(DeliveryRequests.Transfer request) {
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
if (!requestHash.equals(outbound.getRequestHash()))
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
return result(outbound, requireDeliveries(outbound.getCustomerId(), outbound.getId()), true);
}
String transferRequestHash(DeliveryRequests.Transfer request) {
// A new command key cannot change an already transferred dispatch's business content.
com.fasterxml.jackson.databind.node.ObjectNode content = objectMapper.valueToTree(request);
content.remove("idempotencyKey");
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) {
content.remove("feeCoverage"); content.remove("feeCoverageCargoHash");
}
return hash(content);
}
private void validateTransferFees(DeliveryRequests.Transfer request, boolean requiresDeliveryFee, Set<String> covered) {
List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees();
if (inputs.isEmpty()) throw conflict("Delivery fee is required");
Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE");
Set<String> seen = new HashSet<>();
for (DeliveryRequests.FeeItem input : inputs) {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if (!seen.add(input.direction() + ":" + feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if ("RECEIVABLE".equals(input.direction()) && covered.contains(feeType)) throw conflict("本次货物的该服务已由原费用覆盖,请勿重复登记应收");
if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
throw conflict("Delivery fees only support CNY or USD");
if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
}
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required");
if (requiresDeliveryFee && !covered.contains("DELIVERY_FEE") && !seen.contains("RECEIVABLE:DELIVERY_FEE")) throw conflict("尚未有约定运费的货物须填写派送费");
if (!requiresDeliveryFee && inputs.stream().anyMatch(item -> "DELIVERY_FEE".equalsIgnoreCase(item.feeType().trim())
&& "RECEIVABLE".equals(item.direction()))) throw conflict("本排车全部货物已带入外接约定运费,请勿重复填写派送应收");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
&& !seen.contains("LOADING_FEE")) {
&& !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) {
throw conflict("Loading fee is required for floor or mixed loading");
}
if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) {
if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment");
if (!seen.contains("APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
if (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
}
}
@@ -382,10 +476,6 @@ public class DeliveryTransferService {
}
}
String transferRequestHash(DeliveryRequests.Transfer request) {
return hash(request);
}
public List<DeliveryResponses.OneClickCandidate> oneClickCandidates(String customerId, String warehouseId) {
return dropshipProfiles.listOutboundCandidates(customerId,
StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream()
@@ -396,18 +486,12 @@ public class DeliveryTransferService {
public List<DeliveryResponses.ReturnOutboundCandidate> returnOutboundCandidates(String customerId, String warehouseId) {
if (!StringUtils.hasText(warehouseId)) return List.of();
return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream()
.filter(carton -> !"PARCEL".equals(carton.getSourceUnitType()))
.map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(),
c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(),
value(c.getTotalQuantity()), c.getStatus())).toList();
}
/** Overseas gateway freight was removed; callers must provide delivery fees explicitly. */
@Transactional(readOnly = true)
public com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview externalFreight(String dispatchNo) {
return new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview(
"", true, List.of(), List.of());
}
@Transactional
public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId,
DeliveryRequests.ReturnOutbound request) {
@@ -454,7 +538,7 @@ public class DeliveryTransferService {
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId()));
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount(0);
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
@@ -693,6 +777,11 @@ public class DeliveryTransferService {
@Transactional
public DeliveryResponses.PdaTask pdaPick(String warehouseId, String operatorId, String taskNo,
DeliveryRequests.PdaPick request) {
return pickResolved(warehouseId, operatorId, taskNo, request, false);
}
private DeliveryResponses.PdaTask pickResolved(String warehouseId, String operatorId, String taskNo,
DeliveryRequests.PdaPick request, boolean palletScan) {
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
required(warehouseId, "PDA warehouse binding is required"));
if (task == null) throw notFound("PDA task not found in the bound warehouse");
@@ -706,11 +795,13 @@ public class DeliveryTransferService {
throw conflict("Idempotency key was reused with a different outbound pick request");
}
OutboundOrder replayOutbound = requireOutbound(task);
OutboundPalletItem replayItem = outboundPallets.selectById(request.outboundPalletItemId());
if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(replayOutbound.getOutboundType()) && replayItem != null && replayItem.getWarehousePalletId() == null))
requireReturnScan(replayOutbound, outboundPallets.selectById(request.outboundPalletItemId()), request);
return taskResult(requireOutbound(task), task);
if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType()) || "RELABEL_OUTBOUND".equals(replayOutbound.getOutboundType())) {
OutboundPalletItem replayItem = outboundPallets.selectById(request.outboundPalletItemId());
if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())
|| (replayItem != null && replayItem.getWarehousePalletId() == null))
requireReturnScan(replayOutbound, replayItem, request);
}
return taskResult(replayOutbound, task);
}
OutboundOrder existingOutbound = requireOutbound(task);
OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo());
@@ -725,6 +816,8 @@ public class DeliveryTransferService {
if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity");
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) {
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) && StringUtils.hasText(item.getPalletNo()) && !palletScan)
throw conflict("整托出库请扫描托盘码,不允许逐箱提交");
requireReturnScan(outbound, item, request);
if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱不可重复出库");
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
@@ -906,12 +999,19 @@ public class DeliveryTransferService {
}
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
var expectedMarks = palletItems.stream().filter(item -> value(item.getPickedPackageCount()) < value(item.getPackageCount()))
.map(OutboundPalletItem::getCartonMarks).collect(Collectors.toSet());
var physical = returnCartons.listActivePallet(boundWarehouseId, palletCode);
if (physical.size() != expectedMarks.size() || physical.stream().anyMatch(carton ->
!task.getCustomerId().equals(carton.getCustomerId()) || !expectedMarks.contains(carton.getCartonMark())
|| !"OUTBOUND_PENDING".equals(carton.getStatus())))
throw conflict("托盘内容或出库预留已变化,请刷新核对");
DeliveryResponses.PdaTask result = taskResult(outbound, task);
for (OutboundPalletItem item : palletItems.stream().sorted(Comparator.comparing(OutboundPalletItem::getId)).toList()) {
if (value(item.getPickedPackageCount()) >= value(item.getPackageCount())) continue;
result = pdaPick(boundWarehouseId, operatorId, code,
result = pickResolved(boundWarehouseId, operatorId, code,
new DeliveryRequests.PdaPick(item.getId(), null, 1L,
childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())));
childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())), true);
}
return result;
}
@@ -1043,20 +1143,24 @@ public class DeliveryTransferService {
}
@Transactional
public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) {
cancelOutboundResolved(outboundNo, "PLATFORM", request);
}
private void cancelOutboundResolved(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
required(request == null ? null : request.reason(), "取消原因不能为空");
// Confirmation locks the relabel application before its outbound document.
// Use the same order here to avoid cancellation/confirmation deadlocks.
OutboundOrder found = outboundOrders.findGlobalByNo(trim(outboundNo));
RelabelApplication application = found != null && "RELABEL_OUTBOUND".equals(found.getOutboundType())
? relabelApplications.lockByOutbound(found.getCustomerId(), found.getOutboundNo()) : null;
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
if (observed == null) throw notFound("Outbound order not found");
// Match dispatch picking and relabel confirmation lock order before locking outbound.
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
RelabelApplication application = "RELABEL_OUTBOUND".equals(observed.getOutboundType())
? relabelApplications.lockByOutbound(observed.getCustomerId(), observed.getOutboundNo()) : null;
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found");
if ("CANCELLED".equals(outbound.getStatus())) return;
if (outbound.getDispatchOrderId() != null) {
if (dispatch == null || !Objects.equals(dispatch.getId(), outbound.getDispatchOrderId())
|| !"PENDING_DEPARTURE".equals(dispatch.getStatus()) || value(dispatch.getPickedPackageCount()) > 0
|| value(dispatch.getShippedPackageCount()) > 0) throw conflict("排车已开始执行,不能直接取消出库");
fleetBindings.requireUnbound(dispatch.getId());
}
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
throw conflict("Only an unexecuted outbound order can be cancelled");
}
@@ -1099,6 +1203,22 @@ public class DeliveryTransferService {
}
}
for (DeliveryOrder delivery : existingDeliveries) {
int voidRequests = 0;
for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) {
if ("RECEIVABLE".equals(fee.getDirection()) && "DELIVERY_FEE".equals(fee.getFeeItemCode())
&& "APPROVED".equals(fee.getReviewStatus()) && "ACTIVE".equals(fee.getSourceStatus())) {
feeSources.voidSource(fee.getSourceFeeId(), operatorId, new OverseasFeeSourceDtos.VoidRequest(
fee.getSourceVersion(), "取消出库 " + outboundNo + ";详见派送单取消原因",
"CANCEL_OUTBOUND:" + outbound.getId() + ":" + fee.getSourceFeeId()));
voidRequests++;
}
}
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND",
"出库取消;" + voidRequests + "笔未履行派送应收已申请撤销,其他费用保留待核对", now);
String reason = request.reason().trim();
for (int offset = 0; offset < reason.length(); offset += 480)
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON",
"(" + (offset / 480 + 1) + ") " + reason.substring(offset, Math.min(offset + 480, reason.length())), now);
delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1);
delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
}
@@ -1124,18 +1244,30 @@ public class DeliveryTransferService {
return;
}
if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList();
for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) {
var order = tailOrders.lockById(customerId, selected.getId());
if (order == null || !outboundNo.equals(order.getOutboundOrderNo())) continue;
var previous = histories.selectOne(new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getCustomerId, customerId).eq(LastlegOrderStatusHistory::getOrderId, order.getId())
.in(LastlegOrderStatusHistory::getEventId, transferEvents)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt, LastlegOrderStatusHistory::getId).last("LIMIT 1"));
if (previous == null || !StringUtils.hasText(previous.getFromStatus())) throw conflict("缺少本次转派送前的订单状态记录,请先核对来源");
String from = order.getCurrentStatus();
if (tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<LastlegOrder>()
.eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo)
.set(LastlegOrder::getCurrentStatus, previous.getFromStatus())
.set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED")
.set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null)
.set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1)
throw conflict("订单关联已变化,请刷新后重试");
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
}
}
}
@Transactional
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
cancelOutboundResolved(outboundNo, operatorId, request);
}
public List<DeliveryResponses.PdaTask> pdaTasks(String warehouseId, String status) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream()
@@ -1153,7 +1285,7 @@ public class DeliveryTransferService {
item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()),
value(item.getPickedPackageCount()), item.getStatus(),
("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, item)
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item)
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList();
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
}
@@ -1200,13 +1332,14 @@ public class DeliveryTransferService {
throw conflict("请扫描当前退货出库明细指定的箱唛");
}
private List<DeliveryResponses.PdaPickPosition> returnPickPositions(OutboundOrder outbound, OutboundPalletItem item) {
private List<DeliveryResponses.PdaPickPosition> returnPickPositions(OutboundOrder outbound, OutboundPdaTask task, OutboundPalletItem item) {
ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks());
if (carton == null || !outbound.getCustomerId().equals(carton.getCustomerId())
|| !outbound.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
|| !returnStock.matchesReturnSource(carton, item.getInboundCode())) throw conflict("退货箱出库来源异常");
return List.of(new DeliveryResponses.PdaPickPosition(null, null, item.getCartonMarks(), item.getSkuCode(),
item.getInboundCode(), "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount())));
item.getInboundCode(), Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())
&& value(item.getPickedPackageCount()) == 0 && "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount())));
}
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
@@ -1341,14 +1474,19 @@ public class DeliveryTransferService {
}
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream()
.filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList();
List<Long> orderIds = items.stream().filter(item -> !"RETURN_RELABEL".equals(item.getSourceStage()))
.map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
var regularItems = items.stream().filter(item -> !"RETURN_RELABEL".equals(item.getSourceStage())).toList();
var picked = dispatchPickOrders.deliveryPickedSkus(customerId, delivery.getDispatchOrderId()).stream()
.filter(row -> regularItems.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList();
Set<Long> sourceOrderIds = new LinkedHashSet<>();
regularItems.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(sourceOrderIds::add);
picked.stream().map(com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku::orderId)
.filter(Objects::nonNull).forEach(sourceOrderIds::add);
List<Long> orderIds = List.copyOf(sourceOrderIds);
Map<Long, LastlegOrder> ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream()
.filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order));
List<LastlegOrderDetail> details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList(
new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
List<DeliveryResponses.DeliverySku> skus = new ArrayList<>(details.stream()
.map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList());
List<DeliveryResponses.DeliverySku> skus = new ArrayList<>(deliveryCargoSkus(regularItems, picked, ordersById, details));
for (DispatchItem item : items) {
if (!"RETURN_RELABEL".equals(item.getSourceStage())) continue;
for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) {
@@ -1363,7 +1501,59 @@ public class DeliveryTransferService {
}
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(), null);
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(),
feeCoverageEvidence(customerId, delivery.getOutboundNo(), items));
}
private List<DeliveryResponses.DeliverySku> deliveryCargoSkus(List<DispatchItem> items,
List<com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku> picked,
Map<Long, LastlegOrder> orders, List<LastlegOrderDetail> details) {
List<DeliveryResponses.DeliverySku> result = new ArrayList<>();
for (DispatchItem item : items) {
var rows = picked.stream().filter(row -> Objects.equals(item.getId(), row.dispatchItemId())).toList();
long total = 0;
for (var row : rows) {
if (row.packages() == null || row.packages() <= 0) throw conflict("当前派送拣货数量无效");
total = Math.addExact(total, row.packages());
String skuCode = trim(row.skuCode());
if ("MIXED".equalsIgnoreCase(skuCode)) skuCode = null;
String currentSku = skuCode;
var matching = currentSku == null ? List.<LastlegOrderDetail>of() : details.stream()
.filter(detail -> Objects.equals(row.orderId(), detail.getOrderId())
&& currentSku.equals(detail.getSkuCode())).toList();
LastlegOrderDetail metadata = matching.size() == 1 ? matching.get(0) : null;
LastlegOrder order = row.orderId() == null ? null : orders.get(row.orderId());
result.add(new DeliveryResponses.DeliverySku(order == null ? item.getInboundCode() : order.getInboundCode(),
order == null ? null : order.getCustomerName(), firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()),
item.getDeliveryAddress(), metadata == null ? null : metadata.getShipmentNo(),
metadata == null ? null : metadata.getFbaTrackingNo(), skuCode,
skuCode == null ? null : row.productName(), Math.toIntExact(row.packages()), null, null));
}
long snapshotPackages = value(item.getPackageCount());
if (total > snapshotPackages) throw conflict("当前派送拣货数量超过冻结排车数量");
if (total < snapshotPackages) {
// Before picking, only the selected dispatch quantity is known; no SKU allocation is invented.
LastlegOrder order = item.getOrderId() == null ? null : orders.get(item.getOrderId());
result.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), order == null ? null : order.getCustomerName(),
firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()), item.getDeliveryAddress(),
null, null, null, null, Math.toIntExact(snapshotPackages - total), null, null));
}
}
return List.copyOf(result);
}
private com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence feeCoverageEvidence(String customerId, String outboundNo, List<DispatchItem> items) {
var outbound = outboundNo == null ? null : outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null || outbound.getFeeCoverageJson() == null) return null;
try {
var evidence = objectMapper.readValue(outbound.getFeeCoverageJson(), com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence.class);
var ids = items.stream().map(DispatchItem::getId).collect(Collectors.toSet());
var cargo = evidence.cargo().stream().filter(row -> ids.contains(row.dispatchItemId())).toList();
var inboundIds = cargo.stream().filter(row -> "FIRSTLEG".equals(row.sourceRegion()))
.map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::sourceInboundId).collect(Collectors.toSet());
var references = evidence.references().stream().filter(row -> inboundIds.contains(row.source().sourceInboundId().toString())).toList();
return new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence(evidence.cargoHash(), cargo, references, evidence.operatorId(), evidence.confirmedAt());
} catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); }
}
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
@@ -1425,7 +1615,6 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
@@ -1482,6 +1671,11 @@ public class DeliveryTransferService {
java.util.function.Function<T, BigDecimal> getter) {
return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static <T> BigDecimal sumKnownPhysicalWeight(List<T> items,
java.util.function.Function<T, BigDecimal> getter) {
if (items.isEmpty() || items.stream().map(getter).anyMatch(weight -> weight == null || weight.signum() <= 0)) return null;
return items.stream().map(getter).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages,
long selectedPackages, int scale) {
if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null
@@ -74,19 +74,20 @@ paths:
'404': { description: 当前绑定仓无此任务 }
/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick:
post:
summary: 扫码拣取一件代发箱件
summary: 按当前出库任务扫码拣货
description: 退货散箱按指定箱唛或SKU提交;选择整托出库的退货任务必须扫描托盘码,不允许逐箱提交。
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundPick' } } } }
responses: { '200': { description: 更新后的统一出库任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '409': { description: 库存、数量或幂等冲突 } }
/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:
post:
summary: 扫描一件代发托盘并原子拣取整托剩余箱件
description: 仅适用于一件代发出库;按托盘汇总本任务内全部 SKU 和未拣箱唛,任一库存校验失败则整托回滚。
summary: 扫描出库托盘并原子拣取整托剩余箱件
description: 适用于一件代发、换标、仓储转运和退货整托出库;退货按每箱一条冻结明细核对整托全部预留箱、客户和状态,内容变化拒绝提交。任一库存校验失败则整托回滚。
parameters:
- { name: taskNo, in: path, required: true, schema: { type: string } }
- { name: palletNo, in: path, required: true, schema: { type: string } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } }
responses: { '200': { description: 整托拣货后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '404': { description: 当前任务没有该托盘 }, '409': { description: 非一件代发、库存不足或幂等冲突 } }
responses: { '200': { description: 整托拣货后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '404': { description: 当前任务没有该托盘 }, '409': { description: 出库类型不支持、托盘内容变化、库存不足或幂等冲突 } }
/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:
post:
summary: 完成一件代发出库任务
@@ -123,6 +123,7 @@ class DeliveryTransferServiceTest {
@Mock ReturnInventoryCartonMapper returnCartons;
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
@Mock ReturnInventoryBalanceMapper returnBalances;
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock;
private DeliveryTransferService service;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
@@ -164,7 +165,8 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems);
ReflectionTestUtils.setField(service, "returnBalances", returnBalances);
ReflectionTestUtils.setField(service, "returnStock", returnStock);
org.mockito.Mockito.lenient().when(returnStock.matchesReturnSource(any(), any())).thenCallRealMethod();
}
@Test
@@ -172,7 +174,9 @@ class DeliveryTransferServiceTest {
ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1");
ReturnInventoryCarton second = returnCarton("RC-2", "PAL-1");
when(returnCartons.lockMark("RC-1")).thenReturn(first);
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first, second));
ReturnInventoryCartonItem content = new ReturnInventoryCartonItem();
content.setSkuCode("SKU-A"); content.setQuantity(1L);
when(returnCartonItems.lockCarton(first.getId())).thenReturn(List.of(content));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-1"), null, "return-1"));
@@ -252,16 +256,14 @@ class DeliveryTransferServiceTest {
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(801L)).thenReturn(item);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.lockMark("RC-1")).thenReturn(carton);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "wrong-sku", List.of(), "SKU-B"))).getStatusCode().value());
service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "correct-sku", List.of(), "SKU-A"));
assertEquals("OUTBOUNDED", carton.getStatus());
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"),
any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong());
assertEquals("PICKED", item.getStatus());
}
@Test
@@ -291,7 +293,7 @@ class DeliveryTransferServiceTest {
}
@Test
void returnCartonPickDeductsEverySkuOnce() {
void returnCartonPickDelegatesInventoryDeductionOnce() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-1");
outbound.setCustomerId("TENANT-A"); outbound.setOverseasWarehouseId("WH-1");
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
@@ -309,17 +311,12 @@ class DeliveryTransferServiceTest {
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(801L)).thenReturn(item);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.lockMark("RC-1")).thenReturn(carton);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L)).thenReturn(1);
service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "scan-rc-1", List.of("RC-1")));
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L);
assertEquals("OUTBOUNDED", carton.getStatus());
verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"),
any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong());
assertEquals("PICKED", item.getStatus());
}
@@ -350,36 +347,33 @@ class DeliveryTransferServiceTest {
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A");
item.setOutboundOrderId(301L); item.setPalletNo("PAL-1"); item.setCartonMarks("RC-1,RC-2");
item.setPackageCount(2); item.setPickedPackageCount(0);
item.setPackageCount(1); item.setPickedPackageCount(0); item.setCartonMarks("RC-1");
OutboundPalletItem secondItem = new OutboundPalletItem(); secondItem.setId(802L); secondItem.setCustomerId("TENANT-A");
secondItem.setOutboundOrderId(301L); secondItem.setPalletNo("PAL-1"); secondItem.setCartonMarks("RC-2");
secondItem.setPackageCount(1); secondItem.setPickedPackageCount(0);
ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1"); first.setId(901L); first.setStatus("OUTBOUND_PENDING");
ReturnInventoryCarton second = returnCarton("RC-2", "PAL-1"); second.setId(902L); second.setStatus("OUTBOUND_PENDING");
ReturnInventoryCartonItem firstSku = new ReturnInventoryCartonItem(); firstSku.setSkuCode("SKU-A"); firstSku.setQuantity(3L);
ReturnInventoryCartonItem secondSku = new ReturnInventoryCartonItem(); secondSku.setSkuCode("SKU-B"); secondSku.setQuantity(2L);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item, secondItem));
when(outboundPallets.selectById(801L)).thenReturn(item);
when(outboundPallets.selectById(802L)).thenReturn(secondItem);
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first));
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPickPallet("WH-1", "U-1", "PDA-701",
"PAL-1", new DeliveryRequests.PdaPalletPick("scan-pal-1"))).getStatusCode().value());
verify(returnBalances, never()).shipCartonSku(any(), any(), any(), anyLong());
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first, second));
when(returnCartons.lockMark("RC-1")).thenReturn(first);
when(returnCartons.lockMark("RC-2")).thenReturn(second);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(firstSku));
when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(secondSku));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L)).thenReturn(1);
service.pdaPickPallet("WH-1", "U-1", "PDA-701", "PAL-1", new DeliveryRequests.PdaPalletPick("scan-pal-2"));
assertEquals("OUTBOUNDED", first.getStatus()); assertEquals("OUTBOUNDED", second.getStatus());
assertEquals(2, item.getPickedPackageCount());
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L);
assertEquals(1, item.getPickedPackageCount()); assertEquals(1, secondItem.getPickedPackageCount());
verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"), any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong());
verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"), any(), org.mockito.ArgumentMatchers.eq("RC-2"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong());
}
private static ReturnInventoryCarton returnCarton(String mark, String palletCode) {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(Integer.toUnsignedLong(mark.hashCode()));
carton.setCustomerId("TENANT-A"); carton.setOverseasWarehouseId("WH-1");
carton.setSourceReturnNo("RT-1"); carton.setSourceUnitType("BOX");
carton.setCartonMark(mark); carton.setPalletCode(palletCode); carton.setStatus("AVAILABLE");
@@ -135,6 +135,7 @@ class ReturnOutboundClosureTest {
@Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() {
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1");
outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound);
when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound);
OutboundPalletItem item = new OutboundPalletItem(); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(1);
when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item));
@@ -45,7 +45,7 @@ class ReturnOutboundPdaPersistenceTest {
jdbc = new JdbcTemplate(ds);
// Persist production entities through production MyBatis mappers, with real transactions.
for (Class<?> entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class,
OutboundPdaPick.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
OutboundPdaPick.class, DeliveryOrder.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
ReturnInventoryOutboundMovement.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) {
String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers()))
.map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " "
@@ -56,13 +56,16 @@ class ReturnOutboundPdaPersistenceTest {
}
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
for (Class<?> mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class,
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class,
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.class, ReturnInventoryCartonMapper.class,
ReturnInventoryCartonItemMapper.class, ReturnInventoryBalanceMapper.class, ReturnInventoryMovementMapper.class)) configuration.addMapper(mapper);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
var session = new SqlSessionTemplate(factory.getObject());
movementMapper = session.getMapper(ReturnInventoryMovementMapper.class);
balanceMapper = session.getMapper(ReturnInventoryBalanceMapper.class);
var target = new DeliveryTransferService();
ReflectionTestUtils.setField(target, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(target, "returnCartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class));
ReflectionTestUtils.setField(target, "deliveryOrders", session.getMapper(DeliveryOrderMapper.class));
ReflectionTestUtils.setField(target, "outboundOrders", session.getMapper(OutboundOrderMapper.class));
ReflectionTestUtils.setField(target, "outboundPallets", session.getMapper(OutboundPalletItemMapper.class));
ReflectionTestUtils.setField(target, "pdaTasks", session.getMapper(OutboundPdaTaskMapper.class));
@@ -77,7 +80,7 @@ class ReturnOutboundPdaPersistenceTest {
stockProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
ReflectionTestUtils.setField(target, "returnStock", stockProxy.getProxy());
var numbers = mock(LastlegOrderNoGenerator.class);
when(numbers.next(eq("RETURN_OUTBOUND"), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2));
when(numbers.next(anyString(), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2));
ReflectionTestUtils.setField(target, "orderNoGenerator", numbers);
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
@@ -119,11 +122,34 @@ class ReturnOutboundPdaPersistenceTest {
assertEquals(1, after.items().stream().mapToInt(item -> item.pickedPackageCount()).sum());
assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum());
}
@Test void wholeReturnPalletRejectsPartialScansAndChangedContentsThenShipsAtomically() {
jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id IN (1,2)");
var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound(
"recipient", "WH-1", "RT-1", List.of("BOX-A", "BOX-B"), null, "pallet-create", null, "PALLET"));
var items = service.pdaTaskDetail("WH-1", task.taskNo()).items();
assertEquals(2, items.size());
assertTrue(items.stream().allMatch(item -> item.packageCount() == 1 && "PAL-1".equals(item.palletNo())));
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
new DeliveryRequests.PdaPick(items.get(0).outboundPalletItemId(), null, 1L, "partial", List.of("BOX-A"))));
jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id=3");
var before = stockSnapshot();
assertThrows(ResponseStatusException.class, () -> service.pdaPickPallet("WH-1", "operator", task.taskNo(),
"PAL-1", new DeliveryRequests.PdaPalletPick("whole")));
assertEquals(before, stockSnapshot());
jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code=NULL WHERE id=3");
service.pdaPickPallet("WH-1", "operator", task.taskNo(), "PAL-1", new DeliveryRequests.PdaPalletPick("whole"));
var after = stockSnapshot();
service.pdaPickPallet("WH-1", "operator", task.taskNo(), "PAL-1", new DeliveryRequests.PdaPalletPick("whole"));
assertEquals(after, stockSnapshot());
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
assertEquals(10L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class));
}
@Test void wrongScanAndWrongReplayPayloadLeaveStoredInventoryUnchanged() {
var task = create("scan", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "bad", List.of("BOX-B"))));
assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
assertEquals("OUTBOUND_PENDING", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
var valid = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "good", List.of("BOX-A"));
service.pdaPick("WH-1", "operator", task.taskNo(), valid);
service.pdaPick("WH-1", "operator", task.taskNo(), valid);
@@ -165,7 +191,7 @@ class ReturnOutboundPdaPersistenceTest {
jdbc.update("UPDATE ll_return_inventory_carton SET source_return_no='RT-OTHER' WHERE id=1");
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "stale-source", List.of("BOX-A"))));
assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
assertEquals("OUTBOUND_PENDING", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
assertEquals(0, jdbc.queryForObject("SELECT picked_package_count FROM ll_outbound_pallet_item", Integer.class));
}
@@ -177,7 +203,7 @@ class ReturnOutboundPdaPersistenceTest {
var stockBefore = stockSnapshot();
jdbc.execute("ALTER TABLE ll_outbound_pda_pick ADD CONSTRAINT fail_pick CHECK(idempotency_key <> 'rollback-pick')");
assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), request));
assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
assertEquals("OUTBOUND_PENDING", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
var unchanged = service.pdaTaskDetail("WH-1", task.taskNo());
assertEquals("PENDING", unchanged.task().status());
@@ -94,7 +94,8 @@ class FleetTripPersistenceTest {
"ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
assertThat(match.find()).as(table).isTrue();
jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
jdbc.execute(match.group().replace(" JSON", " LONGTEXT")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id) IS NOT NULL\\)", "(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
}
var configuration = new MybatisConfiguration();
for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class,
@@ -18,7 +18,7 @@ class OverseasMasterDataOpenApiContractTest {
.contains("PartnerWarehouseOption:")
.contains("/api/platform/v1/last-leg/master-data/exchange-rates:")
.contains("/api/platform/v1/last-leg/master-data/vehicles:")
.contains("platform:overseas-master-data:exchange-rate:export")
.doesNotContain("/api/platform/v1/last-leg/master-data/exchange-rates/export:")
.contains("platform:overseas-master-data:vehicle:status")
.contains("不包含派送履约或车辆更换历史")
.doesNotContain("deliveryOrders:")
@@ -24,6 +24,6 @@ class PlatformMonthlyExchangeRateControllerContractTest {
assertThat(authorities.get("create")).contains("platform:overseas-master-data:exchange-rate:create");
assertThat(authorities.get("update")).contains("platform:overseas-master-data:exchange-rate:edit");
assertThat(authorities.get("status")).contains("platform:overseas-master-data:exchange-rate:status");
assertThat(authorities.get("export")).contains("platform:overseas-master-data:exchange-rate:export");
assertThat(authorities).doesNotContainKey("export"); // Export is served by the File download center.
}
}
@@ -53,7 +53,7 @@ class LastlegOrderSailingStageTest {
assertEquals(List.of(3L, 4L, 7L, 8L, 9L), find(db, null, SailingStage.AFTER_DEPARTURE, null));
assertEquals(List.of(3L, 4L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, null));
assertEquals(List.of(3L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT"));
assertEquals(15, find(db, null, null, null).size());
assertEquals(java.util.stream.LongStream.rangeClosed(1, 14).boxed().toList(), find(db, null, null, null)); // Discard applications have their own workflow and are excluded.
}
}
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse']
test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 157]]) {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 159]]) {
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 +39,7 @@ 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|157) AS initialized_tables;$/)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|159) AS initialized_tables;$/)
}
})
@@ -65,7 +65,7 @@ test('退回实收按真实流水唯一保存,重派拣货必须明确唯一
}
const pick = table(overseas, 'baiyue_lastleg', 'll_dispatch_pda_pick_order')
assert.match(pick, /\breceipt_id BIGINT NULL/i)
assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL AND return_receipt_id IS NULL\) OR \(receipt_id IS NULL AND return_receipt_id IS NOT NULL\)\)/i)
assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL\) \+ \(return_receipt_id IS NOT NULL\) \+ \(return_carton_id IS NOT NULL\) = 1\)/i)
})
test('车辆报价与实测记录保留业务去重和不可变版本', () => {
@@ -121,7 +121,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
})
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => {
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 417, iam_role_permission: 598,
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 419, iam_role_permission: 600,
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)
@@ -222,3 +222,26 @@ test('最终业务触发器与循环外键均存在,不留下发布过程', ()
assert.ok(disable >= 0 && enable > disable)
assert.ok(!cn.slice(disable, enable).includes('INSERT INTO'))
})
test('退货合箱最终字段直接进入空库建表定义', () => {
for (const [name, fields] of Object.entries({
ll_return_packing_plan: ['source_items_json', 'result_marks_json', 'result_labels_json', 'request_hash', 'completion_hash'],
ll_return_packing_trace: ['source_carton_mark', 'target_carton_mark', 'return_no', 'sku_code'],
ll_return_inventory_carton_item: ['packing_reserved_quantity'],
ll_return_inventory_operation: ['result_json'],
ll_dispatch_pda_pick: ['return_carton_id'],
ll_dispatch_pda_pick_order: ['return_carton_id', 'return_receipt_id']
})) {
const definition = table(overseas, 'baiyue_lastleg', name)
for (const field of fields) assert.ok(definition.includes(field), name + '.' + field)
}
})
test('退货预占状态符合空库数量和状态约束', () => {
const carton = table(overseas, 'baiyue_lastleg', 'll_return_inventory_carton')
for (const name of ['chk_ll_return_inventory_carton_quantity', 'chk_ll_return_inventory_carton_status']) {
const constraint = carton.split('CONSTRAINT ' + name)[1].split('\n')[0]
for (const status of ['PACKING_RESERVED', 'OUTBOUND_PENDING']) assert.ok(constraint.includes(status), name + ':' + status)
}
})