Compare commits

...
73 changed files with 2337 additions and 476 deletions
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
+15 -14
View File
@@ -1487,22 +1487,21 @@ CREATE TABLE ll_local_looseboard_order (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货',
package_count int NOT NULL DEFAULT '0' COMMENT '货物件数',
total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)',
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式',
requested_date date DEFAULT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
@@ -1516,7 +1515,7 @@ CREATE TABLE ll_local_looseboard_order (
UNIQUE KEY uk_ll_local_looseboard_key (customer_id,idempotency_key),
UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id),
KEY idx_ll_local_looseboard_list (customer_id,status,created_at),
KEY idx_ll_local_looseboard_date (customer_id,requested_date,created_at)
KEY idx_ll_local_looseboard_date (customer_id,pickup_at,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单';
CREATE TABLE ll_monthly_exchange_rate (
@@ -2725,13 +2724,12 @@ CREATE TABLE ll_us_truck_order (
id bigint NOT NULL COMMENT '主键ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '订单号',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报',
package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效',
received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数',
allocated_pallet_count int NOT NULL DEFAULT '0' COMMENT '已分配到合车行程的托盘数',
@@ -2739,9 +2737,12 @@ CREATE TABLE ll_us_truck_order (
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算金额',
currency varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人联系方式',
requested_date date NOT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人',
origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人',
destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区',
delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '美国卡车订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
@@ -2754,7 +2755,7 @@ CREATE TABLE ll_us_truck_order (
UNIQUE KEY uk_ll_us_truck_order_no (customer_id,order_no),
UNIQUE KEY uk_ll_us_truck_order_key (customer_id,idempotency_key),
KEY idx_ll_us_truck_order_list (customer_id,status,created_at),
KEY idx_ll_us_truck_order_date (customer_id,requested_date,created_at)
KEY idx_ll_us_truck_order_date (customer_id,pickup_at,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车运输订单';
CREATE TABLE ll_us_truck_order_operation (
Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

+90
View File
@@ -0,0 +1,90 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1280" viewBox="0 0 1800 1280">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#335c81"/></marker>
<marker id="greenArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#18865b"/></marker>
<marker id="redArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#c94d4d"/></marker>
<style>
.title{font:700 34px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.sub{font:400 17px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
.lane{font:700 20px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.boxTitle{font:700 19px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.boxText{font:400 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#36566d}
.small{font:400 14px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
.assert{font:500 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#23445a}
.line{stroke:#335c81;stroke-width:3;fill:none;marker-end:url(#arrow)}
.gline{stroke:#18865b;stroke-width:3;fill:none;marker-end:url(#greenArrow)}
.rline{stroke:#c94d4d;stroke-width:3;fill:none;marker-end:url(#redArrow)}
</style>
</defs>
<rect width="1800" height="1280" fill="#f6f9fc"/>
<text x="70" y="62" class="title">海外业务|库存变动测试总流程图</text>
<text x="70" y="94" class="sub">测试看库存:先看库存状态,再看数量、位置、流水和幂等;灰色节点只改状态/资源,不直接改可用库存。</text>
<g transform="translate(70,125)">
<rect width="1660" height="58" rx="12" fill="#e9f1f8"/>
<circle cx="28" cy="29" r="10" fill="#18865b"/><text x="50" y="35" class="small">绿色:库存增加/恢复</text>
<circle cx="250" cy="29" r="10" fill="#c94d4d"/><text x="272" y="35" class="small">红色:库存扣减</text>
<circle cx="450" cy="29" r="10" fill="#e19a28"/><text x="472" y="35" class="small">橙色:冻结/释放</text>
<circle cx="690" cy="29" r="10" fill="#7657a8"/><text x="712" y="35" class="small">紫色:SKU/箱唛变化</text>
<circle cx="960" cy="29" r="10" fill="#78909c"/><text x="982" y="35" class="small">灰色:状态/资源变化</text>
<text x="1260" y="35" class="small">所有写操作:租户隔离 + 版本/锁 + 幂等键</text>
</g>
<g transform="translate(70,220)">
<rect width="1660" height="196" rx="16" fill="#edf5ee" stroke="#b7d9c2" stroke-width="2"/>
<text x="24" y="34" class="lane">A. 库存来源与入仓</text>
<rect x="24" y="58" width="240" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">头程装柜完成事件</text><text x="44" y="116" class="boxText">生成尾程单 / 冻结明细</text><text x="44" y="140" class="small">在途:不可用库存</text>
<path d="M264 106 H330" class="line"/>
<rect x="330" y="58" width="250" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
<text x="350" y="88" class="boxTitle">平台转换入仓计划</text><text x="350" y="116" class="boxText">按入仓单 / 推荐托数建计划</text><text x="350" y="140" class="small">仍未形成可用库存</text>
<path d="M580 106 H646" class="line"/>
<rect x="646" y="58" width="290" height="96" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
<text x="666" y="88" class="boxTitle">PDA 签收 → 实收 → 上架</text><text x="666" y="116" class="boxText">完成后入仓库存入账</text><text x="666" y="140" class="small">AVAILABLE + 位置 + 库存流水</text>
<path d="M936 106 H1002" class="gline"/>
<rect x="1002" y="58" width="280" height="96" rx="12" fill="#e6f6ed" stroke="#18865b" stroke-width="2"/>
<text x="1022" y="88" class="boxTitle">可用库存池</text><text x="1022" y="116" class="boxText">客户 × 海外仓 × 目的仓</text><text x="1022" y="140" class="small">入仓单汇总;托盘只做作业维度</text>
<rect x="1316" y="58" width="310" height="96" rx="12" fill="#f0f2f4" stroke="#9baab4" stroke-width="2"/>
<text x="1336" y="88" class="boxTitle">门禁与失败</text><text x="1336" y="116" class="boxText">照片、仓绑定、客户/柜号</text><text x="1336" y="140" class="small">任一失败:库存与状态整体回滚</text>
</g>
<g transform="translate(70,450)">
<rect width="1660" height="222" rx="16" fill="#fff8ec" stroke="#efd39a" stroke-width="2"/>
<text x="24" y="34" class="lane">B. 从可用库存出发的四类作业</text>
<rect x="24" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">排车 / 一件代发拣货</text><text x="44" y="116" class="boxText">AVAILABLE → 冻结 → PICKED</text><text x="44" y="144" class="boxText">发车/出库 → 扣减</text><text x="44" y="170" class="small">整托扫描也按入仓单组成核平</text>
<rect x="430" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#7657a8" stroke-width="2"/>
<text x="450" y="88" class="boxTitle">换标</text><text x="450" y="116" class="boxText">冻结 hold_packages</text><text x="450" y="144" class="boxText">旧 SKU 转出 → 新 SKU 转入</text><text x="450" y="170" class="small">箱唛/托盘绑定变化,箱数守恒</text>
<rect x="836" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
<text x="856" y="88" class="boxTitle">仓储转运</text><text x="856" y="116" class="boxText">来源仓冻结/扣减</text><text x="856" y="144" class="boxText">目标仓收货 → 增加</text><text x="856" y="170" class="small">可联动换标;来源/目标核对</text>
<rect x="1242" y="60" width="384" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
<text x="1262" y="88" class="boxTitle">废弃</text><text x="1262" y="116" class="boxText">申请仅锁箱:DISCARD_PENDING</text><text x="1262" y="144" class="boxText">PDA 实际确认 → 扣减 → DISCARDED</text><text x="1262" y="170" class="small">驳回释放;平台结案不再扣一次</text>
</g>
<g transform="translate(70,706)">
<rect width="1660" height="222" rx="16" fill="#eef3fa" stroke="#b7cae2" stroke-width="2"/>
<text x="24" y="34" class="lane">C. 出库后的结果与恢复</text>
<rect x="24" y="60" width="350" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">OUTBOUNDED / 派送中</text><text x="44" y="116" class="boxText">库存已扣减,保留出库事实</text><text x="44" y="144" class="boxText">签收:不再二次扣库存</text><text x="44" y="170" class="small">派送费用与库存动作分开核对</text>
<path d="M374 123 H446" class="line"/>
<rect x="446" y="60" width="380" height="126" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
<text x="466" y="88" class="boxTitle">异常退回入仓</text><text x="466" y="116" class="boxText">只能选原出库拣货事实</text><text x="466" y="144" class="boxText">RETURN_STOCK_IN → 目标托盘/库位</text><text x="466" y="170" class="small">库存恢复 + 异常完成 + 派送 RETURNED</text>
<rect x="900" y="60" width="330" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
<text x="920" y="88" class="boxTitle">盘点调整</text><text x="920" y="116" class="boxText">实盘数量 − 账面数量 = 差异</text><text x="920" y="144" class="boxText">按正/负生成调整流水</text><text x="920" y="170" class="small">无差异不造流水;需 adjust 权限</text>
<rect x="1270" y="60" width="356" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
<text x="1290" y="88" class="boxTitle">移库 / 拆合托 / 换绑</text><text x="1290" y="116" class="boxText">同仓位置或托盘组成变化</text><text x="1290" y="144" class="boxText">数量净额 = 0</text><text x="1290" y="170" class="small">记录位置流水;资源锁防并发</text>
</g>
<path d="M1142 416 V450" class="gline"/><path d="M120 638 V706" class="rline"/><path d="M610 638 V706" class="gline"/>
<path d="M1020 638 V706" class="line"/><path d="M1435 638 V706" class="rline"/>
<g transform="translate(70,965)">
<rect width="1660" height="250" rx="16" fill="#ffffff" stroke="#d5e0e8" stroke-width="2"/>
<text x="24" y="36" class="lane">D. 测试统一断言(每个库存场景都要检查)</text>
<text x="42" y="78" class="assert">① 数量守恒:客户 / 海外仓 / 目的仓 / 入仓单 / 托盘组成逐层核平</text>
<text x="42" y="114" class="assert">② 三账一致:库存余额 = 位置明细汇总 = 库存流水结果</text>
<text x="42" y="150" class="assert">③ 幂等并发:重复事件、重复请求、并发锁定不得产生第二笔变动</text>
<text x="42" y="186" class="assert">④ 原子回滚:权限、门禁、资源锁、跨服务失败时,库存/单据/流水一起回滚</text>
<text x="42" y="222" class="assert">⑤ 隔离校验:客户、仓、来源入仓单、原出库事实不可伪造或串用</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,40 @@
# 海外业务库存变动测试整理
范围依据:`lastleg-service/AGENTS.md`、`docs/backend-business-index.md` 及当前海外仓库存、入仓、出库、换标、转运、废弃、异常退回和 PDA 作业入口。
## 先统一库存口径
- **可用库存**:按客户、海外仓、目的仓、入仓单汇总箱数;SKU、箱唛和托盘是明细快照/作业维度。
- **冻结库存**:拣货、换标、废弃、转运等处理中暂时不可重复使用;冻结不等于已经扣减。
- **库存流水**:每一次入账、冻结、释放、扣减、恢复、SKU 转换和位置变化都应能按业务单号、幂等键追溯。
- **托盘不是库存归属**:合托后一个物理托盘可以包含多个入仓单,出库、换标和退回必须按托盘组成明细核平。
## 测试场景矩阵
| 场景 | 触发与前置 | 库存变动 | 测试重点 |
|---|---|---|---|
| 头程接柜/海外入仓 | 装柜完成事件 → 生成尾程单/入仓计划;PDA 签收、实收、上架、完成 | 在途冻结 → 入仓后 `AVAILABLE` 增加;写库存位置和流水 | 柜号/客户/入仓单隔离;卸柜照片门禁;重复事件不重复入账;失败整事务回滚 |
| 普通排车出库/派送 | 计划已入仓;排车审核、拣货、箱数核平、发车 | `AVAILABLE` → 冻结/已拣;原子出库后可用库存扣减 | 整托/部分托按组成明细核平;未审核、数量不平、车辆门禁时不得出库;同键重放幂等 |
| 一件代发 | 已完成入库且有可用箱唛/SKU;创建 `WAIT_PICK` 出库单 | SKU/箱唛库存冻结 → PDA 拣货 → `OUTBOUNDED` 扣减 | 候选只能来自真实可用库存;一单不得跨客户/跨仓;整托扫描任一明细失败必须整体回滚 |
| 客户换标 | 历史入仓库存可用;客户申请、PDA 扫旧 SKU/箱唛、完工、客户确认 | 旧 SKU/箱唛冻结;换标完成后旧库存转出、新 SKU/箱唛转入;库存总箱数守恒 | 同箱多 SKU 不重复冻结;只移动本单 `hold_packages`;已生成出库单禁止再次拆托;照片/复核/客户确认门禁 |
| 仓储转运 | 来源库存可用;创建转运、测量、换标(可选)、出库、目标仓收货 | 来源仓冻结/扣减 → 目标仓入账;换标时 SKU 转换 | 来源客户、入仓单和箱数核对;部分转运数量守恒;目标仓与来源仓不能串;重复完成不重复入账 |
| 货物废弃 | 一件代发/库存货物申请废弃;审批后由绑定海外 PDA 实际确认 | `AVAILABLE` → `DISCARD_PENDING`;实际确认后 SKU、余额、位置扣减并记 `DISCARDED` | 申请阶段只锁箱不扣库存;驳回释放;批准不是实际扣减;实际数量与逐箱流水一致;失败整批回滚 |
| 异常退回入仓 | 只能选择原出库拣货事实;异常单受理并完成退回 | 原出库来源核验 → `RETURN_STOCK_IN` 恢复到真实目标托盘/库位 | 不能伪造出库来源、借用其他客户主单;退回数量与原实发核平;异常单完成与派送 `RETURNED` 同事务 |
| PDA 盘点调整 | 绑定海外仓;盘点锁定库存位置并提交实盘数量 | 按差异产生增加/减少调整流水;无差异不产生虚假变动 | 需要 `inventory:adjust`;并发版本校验;差异正负方向正确;盘点重复提交幂等 |
| PDA 移库/托盘拆合/换绑 | 同仓有效托盘/库位;资源锁成功 | 数量净额不变,仅位置/托盘组成变化,写位置流水 | 不跨仓;托盘组成明细保留;资源锁释放;出库/换标占用时禁止绕过协调单独移动 |
## 不应误判为库存变动
- 目的仓修改、暂扣/解除、报价、排车预排、外接预约、车辆/司机资料:只改变状态、资格、资源占用或费用快照。
- 海外采购样品履约:按当前契约走 Admin 台账,**不接入 PDA,也不写 WMS 可用库存**。
- 退货申请/废弃申请/转派送建单:申请或建单阶段只形成冻结/候选/出库单,必须到实际收货、实际扣减或实际出库节点才确认数量变化。
## 每条库存用例的统一断言
1. 变动前后按客户、海外仓、目的仓、入仓单、托盘组成核对数量守恒。
2. 查询余额、位置明细和库存流水三处结果一致,且业务单号/幂等键可追溯。
3. 重复请求、重复事件、并发操作不得新增第二笔库存变动。
4. 任一校验、权限、资源锁或跨服务依赖失败时,库存、单据状态和流水一起回滚。
5. 客户越权、跨仓、跨客户、伪造来源入仓单/出库单均应拒绝。
流程图:`overseas-inventory-mutation-flow.svg` / `overseas-inventory-mutation-flow.png`
@@ -9,6 +9,8 @@ import java.util.Arrays;
enum BusinessFileCategory {
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -67,7 +69,12 @@ enum BusinessFileCategory {
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
}
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
void validateOwner(long tenantId) {
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
}
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
}
@@ -0,0 +1,167 @@
openapi: 3.0.3
info:
title: 平台排车车辆与司机证据内部文件契约
version: 1.0.0
description: >-
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。
Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。
本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。
统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。
security:
- InternalServiceToken: []
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
post:
operationId: uploadDispatchEvidence
summary: 上传本排车的车辆或司机证据
parameters:
- name: X-Operator-Id
in: header
required: true
description: Lastleg 已认证操作者的正数 Long ID
schema: {$ref: '#/components/schemas/PositiveLongString'}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名
responses:
'200': {$ref: '#/components/responses/MetadataOrError'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
operationId: readDispatchEvidenceMetadata
summary: 按本排车与证据类别联合校验并读取元数据
responses:
'200': {$ref: '#/components/responses/MetadataOrError'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
operationId: downloadDispatchEvidence
summary: 按完整复合归属读取文件内容
responses:
'200':
description: >-
成功返回真实文件;错误返回 application/json 统一业务错误。
A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效,
A0404 表示文件不存在、非有效文件或复合归属不匹配。
headers:
Cache-Control: {schema: {type: string, enum: [no-store]}}
Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名}
Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数}
content:
application/pdf: {schema: {type: string, format: binary}}
image/jpeg: {schema: {type: string, format: binary}}
image/png: {schema: {type: string, format: binary}}
application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}}
components:
securitySchemes:
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
parameters:
TenantId:
name: tenantId
in: path
required: true
description: 两类排车证据只属于平台,客户作用域一律拒绝
schema: {type: string, enum: ['0']}
BusinessType:
name: businessType
in: path
required: true
schema: {$ref: '#/components/schemas/DispatchEvidenceType'}
BusinessId:
name: businessId
in: path
required: true
description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入
schema: {$ref: '#/components/schemas/PositiveLongString'}
FileId:
name: fileId
in: path
required: true
schema: {$ref: '#/components/schemas/PositiveLongString'}
responses:
MetadataOrError:
description: >-
统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件;
A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配;
S0503为存储不可用,S0500为元数据保存失败等内部异常。
content:
application/json:
schema:
oneOf:
- {$ref: '#/components/schemas/MetadataEnvelope'}
- {$ref: '#/components/schemas/ErrorEnvelope'}
schemas:
PositiveLongString:
type: string
pattern: '^[1-9][0-9]*$'
maxLength: 19
description: 正数Long的十进制字符串,值不得超过9223372036854775807
DispatchEvidenceType:
type: string
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
MetadataEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, enum: ['200']}
message: {type: string}
data: {$ref: '#/components/schemas/Metadata'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
Metadata:
type: object
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt]
properties:
fileId: {$ref: '#/components/schemas/PositiveLongString'}
ownerScope: {type: string, enum: [PLATFORM]}
tenantId: {type: string, enum: ['0']}
businessType: {$ref: '#/components/schemas/DispatchEvidenceType'}
businessId: {$ref: '#/components/schemas/PositiveLongString'}
fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]}
sizeBytes:
type: string
pattern: '^[1-9][0-9]*$'
description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串
example: '1024'
createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移}
ErrorEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]}
message: {type: string}
data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
@@ -0,0 +1,245 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.controller.InternalBusinessFileController;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DispatchEvidenceFileTest {
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
private static final int MAX_BYTES = 10 * 1024 * 1024;
@TempDir Path root;
/** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */
@BeforeAll static void metadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"),
FileObjectEntity.class);
}
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var upload = pdf();
for (long customer : new long[]{-1, 1, 900}) {
assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class);
}
for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) {
assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class);
verifyNoInteractions(files, storage);
}
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception {
var storage = storage();
var category = BusinessFileCategory.require(type);
for (var file : new MockMultipartFile[]{pdf(),
new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")),
new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) {
var saved = storage.store(0, "71", file, category);
assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes());
assertThat(saved.contentType()).isEqualTo(file.getContentType());
assertThat(saved.size()).isEqualTo(file.getSize());
assertThat(saved.sha256()).matches("[0-9a-f]{64}");
}
for (var file : new MockMultipartFile[]{
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]),
new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)),
new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")),
new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")),
new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()),
new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()),
new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) {
assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class);
}
/** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception {
var storage = storage();
var category = BusinessFileCategory.require(type);
byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES);
var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content);
var stored = storage.store(0, "71", boundary, category);
assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES);
var oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1);
assertThatThrownBy(() -> storage.store(0, "71", oversized, category))
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
verify(oversized, never()).getInputStream();
verify(oversized, never()).transferTo(any(Path.class));
}
/** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = storage();
var service = service(files, storage);
var ids = new AtomicLong(500);
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
FileObjectEntity row = call.getArgument(0);
row.setId(ids.incrementAndGet());
return 1;
});
var first = service.upload(0, type, "71", 7, pdf());
var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf",
"%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII));
var second = service.upload(0, type, "71", 7, secondFile);
var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
verify(files, times(2)).insert(rows.capture());
assertThat(first.ownerScope()).isEqualTo("PLATFORM");
assertThat(first.tenantId()).isZero();
assertThat(first.businessType()).isEqualTo(type);
assertThat(first.businessId()).isEqualTo("71");
assertThat(first.fileId()).isEqualTo(501L);
assertThat(second.fileId()).isEqualTo(502L);
assertThat(first.fileName()).isEqualTo("evidence.pdf");
assertThat(first.contentType()).isEqualTo("application/pdf");
assertThat(first.sizeBytes()).isEqualTo(pdf().getSize());
assertThat(first.sha256()).matches("[0-9a-f]{64}");
assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt());
assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey());
assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes());
assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes());
verify(files, never()).updateById(any(FileObjectEntity.class));
}
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var row = new FileObjectEntity();
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf");
row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize());
row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(boundValue(query, "status")).isEqualTo("ACTIVE");
assertThat(boundValue(query, "deleted")).isEqualTo(false);
return Long.valueOf(0).equals(boundValue(query, "tenant_id"))
&& type.equals(boundValue(query, "business_type"))
&& "71".equals(boundValue(query, "business_id"))
&& Long.valueOf(501).equals(boundValue(query, "id")) ? row : null;
});
when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes());
var metadata = service.metadata(0, type, "71", 501);
assertThat(metadata.fileId()).isEqualTo(501L);
assertThat(metadata.ownerScope()).isEqualTo("PLATFORM");
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes());
verify(storage).read(row.getObjectKey());
clearInvocations(storage);
String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE;
for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) {
assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.download(0, candidate, "71", 501))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
verifyNoInteractions(storage);
}
/** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) {
var service = mock(InternalBusinessFileService.class);
var controller = new InternalBusinessFileController();
ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token");
ReflectionTestUtils.setField(controller, "service", service);
for (String token : new String[]{null, "", "wrong-token"}) {
assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
}
verifyNoInteractions(service);
}
/** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */
private static Object boundValue(LambdaQueryWrapper<FileObjectEntity> query, String column) {
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column
+ " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}").matcher(query.getSqlSegment());
assertThat(matcher.find()).as("复合归属条件 %s", column).isTrue();
return query.getParamNameValuePairs().get(matcher.group(1));
}
/** 创建隔离临时目录中的真实存储,不访问运行环境。 */
private BusinessFileStorage storage() {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
/** 复用生产服务,数据库边界由 Mapper 替身记录实际查询。 */
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
return service;
}
/** 提供具有真实 PDF 文件头的最小上传内容。 */
private static MockMultipartFile pdf() {
return new MockMultipartFile("file", "evidence.pdf", "application/pdf",
"%PDF-1.7\nevidence".getBytes(StandardCharsets.US_ASCII));
}
/** 使用图像编码器生成真实 JPG、PNG,避免测试只验证扩展名。 */
private static byte[] image(String format) throws java.io.IOException {
var output = new ByteArrayOutputStream();
var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB);
assertThat(javax.imageio.ImageIO.write(image, format, output)).isTrue();
return output.toByteArray();
}
}
@@ -35,6 +35,28 @@ public class PlatformDropshipPalletizationController {
@Resource private DropshipPalletizationService service;
@Resource private InboundCartonLabelPrintService printService;
/** 跨票识别箱码只读预览,清点仍按原任务领取并提交。 */
@GetMapping("/cartons/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.ScanContext> resolveWorkspaceCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam @jakarta.validation.constraints.NotBlank @Size(max = 80) String boxCode) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.resolveWorkspaceCarton(warehouseId, boxCode, user.userId()));
}
/** 获取当前实物托盘及客户组成,不要求托盘已与当前票绑定。 */
@GetMapping("/pallets/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.PalletWorkspace> workspacePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam @jakarta.validation.constraints.Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.workspacePallet(warehouseId, palletNo));
}
@GetMapping("/tasks/pending")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
@@ -18,6 +18,15 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {}
/** 跨票只读识别结果;确认清点前仍须领取对应任务。 */
public record ScanContext(Task task, Carton carton) {}
/** 当前实物托盘及逐客户、逐票组成。 */
public record PalletWorkspace(Pallet pallet, List<PalletMember> members) {}
public record PalletMember(Long taskId, Long inboundId, String inboundCode,
Long customerId, String customerName, Integer cartonCount) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record CountCartonRequest(@Positive long warehouseId,
@@ -15,6 +15,45 @@ import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper
public interface DropshipPalletizationMapper {
/** 在指定仓库按稳定箱码识别任务,多个匹配由业务层拒绝。 */
@Select("""
SELECT DISTINCT t.id FROM fl_dropship_pallet_task t
JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id AND o.deleted=0
JOIN fl_inbound_carton_label l ON l.inbound_id=t.inbound_id AND l.tenant_id=t.tenant_id
WHERE t.warehouse_id=#{warehouseId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'
AND o.fulfillment_type='ONE_CLICK_DROPSHIP'
""")
List<Long> workspaceTaskIds(@Param("warehouseId") long warehouseId,@Param("boxCode") String boxCode);
/** 扫码预览为只读查询,不能把未知或已封托托盘默认成开放。 */
@Select("SELECT * FROM fl_dropship_pallet WHERE warehouse_id=#{warehouseId} AND pallet_no=#{palletNo}")
PalletRow workspacePallet(@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo);
/** 按实际成员汇总客户与票,空的预生成托盘不虚构成员。 */
@Select("""
SELECT c.task_id,c.inbound_id,o.inbound_code,c.tenant_id customer_id,o.customer_name,COUNT(*) carton_count
FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id=#{palletId}
GROUP BY c.task_id,c.inbound_id,o.inbound_code,c.tenant_id,o.customer_name
ORDER BY o.inbound_code,c.task_id
""")
List<com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember> workspaceMembers(@Param("palletId") long palletId);
/** 混托成员必须与当前票同渠道、同目的仓,客户可以不同。 */
@Select("""
SELECT COUNT(*) FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
JOIN fl_dropship_pallet_task t ON t.id=#{taskId}
JOIN fl_inbound_order target ON target.id=t.inbound_id AND target.tenant_id=t.tenant_id
WHERE c.pallet_id=#{palletId} AND (
NOT (o.channel_code <=> target.channel_code)
OR NOT (o.destination_type <=> target.destination_type)
OR NOT (o.destination_warehouse_id <=> target.destination_warehouse_id)
OR NOT (o.destination_code <=> target.destination_code))
""")
int workspaceIncompatibleMembers(@Param("taskId") long taskId,@Param("palletId") long palletId);
@Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 仓内跨票扫码与实物托盘组成查询,客户归属始终从箱唛事实取得。 */
@Repository
public class DropshipWorkspaceRepository {
@Resource private DropshipPalletizationMapper mapper;
/** 按仓库和稳定箱码查找作业任务,不接受客户端指定客户归属。 */
public List<Long> taskIdsForBox(long warehouseId, String boxCode) {
return mapper.workspaceTaskIds(warehouseId, boxCode);
}
/** 只读查询托盘,不因扫描托盘产生绑定或状态变化。 */
public PalletRow pallet(long warehouseId, String palletNo) {
return mapper.workspacePallet(warehouseId, palletNo);
}
/** 保留每票客户与任务身份,供混托核对和按原任务移箱。 */
public List<PalletMember> members(long palletId) {
return mapper.workspaceMembers(palletId);
}
/** 归托前锁定实物托盘,与封托互斥。 */
public PalletRow lockPallet(String palletNo) {
return mapper.palletByNo(palletNo);
}
/** 按原始入仓快照检查运输兼容性,不接受客户端声明。 */
public boolean compatible(long taskId, long palletId) {
return mapper.workspaceIncompatibleMembers(taskId, palletId) == 0;
}
}
@@ -21,6 +21,34 @@ import java.util.*;
public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspaceRepository;
/** 只读识别其他客户的箱码;不自动领取、清点或绑定托盘。 */
public ScanContext resolveWorkspaceCarton(long warehouseId,String boxCode,long operatorId){
String code=normalize(boxCode);
if(code==null) throw invalid("箱码不能为空");
List<Long> ids=workspaceRepository.taskIdsForBox(warehouseId,code);
if(ids.isEmpty()) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该一件代发箱码");
if(ids.size()!=1) throw conflict("箱码匹配多个任务,请联系后台核对");
Task target=task(ids.get(0),warehouseId,operatorId);
Carton carton=resolveCarton(target.taskId(),warehouseId,code,operatorId);
// 未领取任务仅允许预览,员工确认后由正式领取与清点接口再次检查权限和状态。
if("PENDING".equals(target.taskStatus()) && Boolean.TRUE.equals(target.palletSetupReady())
&& "请先领取任务".equals(carton.blockReason())){
carton=new Carton(carton.cartonId(),carton.boxCode(),carton.inboundId(),carton.inboundCode(),
carton.skuLines(),carton.countStatus(),carton.palletId(),carton.palletNo(),true,null);
}
return new ScanContext(target,carton);
}
/** 按仓库返回真实托盘状态及全部成员,跨客户不改变每箱的原归属。 */
public PalletWorkspace workspacePallet(long warehouseId,String palletNo){
String code=normalize(palletNo);
if(code==null) throw invalid("托盘码不能为空");
PalletRow row=workspaceRepository.pallet(warehouseId,code);
if(row==null) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该托盘码");
return new PalletWorkspace(palletView(row,null),workspaceRepository.members(row.getId()));
}
@Transactional
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
@@ -195,11 +223,13 @@ public class DropshipPalletizationService {
.map(p->palletView(p,task.getInboundCode())).toList();
}
/** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
PalletRow existing=mapper.palletByNo(palletNo);
PalletRow existing=workspaceRepository.lockPallet(palletNo);
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
if(!workspaceRepository.compatible(task.getId(),existing.getId())) throw conflict("托内货物与当前票的渠道或目的仓不同,不能混托");
return existing;
}
@@ -4,6 +4,26 @@ info:
version: 3.0.0
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
paths:
/api/platform/v1/first-leg/pda-dropship/cartons/resolve:
get:
summary: 同仓跨客户识别箱码并预览原任务
description: 只读,不领取或清点;返回 task 与 carton,确认时仍须领取原任务后按原 taskId 清点。拒绝其他作业员已领取任务及箱码歧义。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/WarehouseId'}
- {name: boxCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 80}}
responses:
'200': {description: 原任务和箱内 SKU 预览}
/api/platform/v1/first-leg/pda-dropship/pallets/resolve:
get:
summary: 查询当前仓库真实托盘状态与逐客户逐票组成
description: 扫描不产生归托;返回 pallet 与 members,成员保留 taskId、inboundId、inboundCode、customerId、customerName、cartonCount。不存在或跨仓的托盘拒绝,已封托返回真实状态。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/WarehouseId'}
- {name: palletNo, in: query, required: true, schema: {type: string, pattern: 'DP-[0-9]{6}-[0-9]{4}'}}
responses:
'200': {description: 实物托盘状态和成员汇总}
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
get:
summary: 查询待领取或本人已领取任务
@@ -24,7 +24,56 @@ class DropshipPalletizationServiceTest {
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService();
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
private final com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspace=mock(com.baiyue.firstleg.repository.DropshipWorkspaceRepository.class);
/** 使用正式仓储入口的等价锁定查询,保留既有扫码测试的事实准备。 */
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); ReflectionTestUtils.setField(service,"workspaceRepository",workspace); when(workspace.lockPallet(anyString())).thenAnswer(call -> mapper.palletByNo(call.getArgument(0))); when(workspace.compatible(anyLong(),anyLong())).thenReturn(true); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
/** 跨客户预览不领取任务,不写入清点事实。 */
@Test void workspaceResolvesPendingCustomerWithoutMutating(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
TaskRow other=task(); other.setTenantId(2L); other.setTaskStatus("PENDING"); other.setAssigneeId(null);
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
when(mapper.task(10L,100L)).thenReturn(other);
when(mapper.label(10L,"BOX-1")).thenReturn(label());
when(mapper.palletCountByInbound(2L,30L)).thenReturn(other.getExpectedPalletCount());
var result=service.resolveWorkspaceCarton(100L," BOX-1 ",7L);
assertThat(result.task().tenantId()).isEqualTo(2L);
assertThat(result.carton().canCount()).isTrue();
verify(mapper,never()).claim(anyLong(),anyLong(),anyLong(),anyString());
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
}
/** 不允许从跨票扫码接管其他作业员的任务。 */
@Test void workspaceRejectsOtherOperatorsTaskAndAmbiguousBox(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
TaskRow other=task(); other.setAssigneeId(9L); when(mapper.task(10L,100L)).thenReturn(other);
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("其他人员");
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L,11L));
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("多个任务");
}
/** 托盘预览必须返回已封托的真实状态,跨仓不存在时拒绝。 */
@Test void workspacePalletNeverInventsOpenState(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
when(workspace.pallet(100L,"DP-260911-0001")).thenReturn(sealedPallet());
when(workspace.members(20L)).thenReturn(java.util.List.of());
when(mapper.cartons(20L)).thenReturn(java.util.List.of());
assertThat(service.workspacePallet(100L,"DP-260911-0001").pallet().status()).isEqualTo("SEALED");
assertThatThrownBy(()->service.workspacePallet(101L,"DP-260911-0001")).isInstanceOf(BizException.class).hasMessageContaining("未找到");
}
/** 即使不同客户允许混托,运输渠道或目的仓不同仍须拒绝。 */
@Test void incompatibleDestinationCannotBeMixed(){
when(mapper.task(10L,100L)).thenReturn(task());
when(mapper.palletCountByInbound(1L,30L)).thenReturn(2);
when(mapper.label(10L,"BOX-1")).thenReturn(label());
when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet());
when(workspace.compatible(10L,20L)).thenReturn(false);
assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),"DP-260911-0001","wrong-destination"),7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("渠道或目的仓不同");
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
}
@Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){
when(mapper.inboundCode(1L,30L)).thenReturn("IN-1");
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.HashSet;
/** 出库查询按实物箱汇总,退货混装 SKU 不重复计箱。 */
@Repository
public class OutboundQuantityRepository {
@Resource private OutboundPalletItemMapper pallets;
/** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */
public Integer boxes(OutboundOrder outbound) {
if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount();
var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId());
if (lines.isEmpty()) return outbound.getPackageCount();
var returnBoxes = new HashSet<String>();
int count = 0;
for (var line : lines) {
if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks());
} else {
count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0));
}
}
return Math.addExact(count, returnBoxes.size());
}
}
@@ -94,6 +94,7 @@ import java.util.stream.Collectors;
@Service
public class DeliveryTransferService {
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
@Resource private DispatchOrderMapper dispatchOrders;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@@ -1631,7 +1632,8 @@ public class DeliveryTransferService {
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
/** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
@@ -13,6 +13,15 @@ import java.util.List;
@Mapper
public interface CargoDiscardInventoryMapper {
// 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。
String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " +
"JOIN ll_inventory_position pos ON pos.id=dp.inventory_position_id AND pos.customer_id=dp.customer_id " +
"AND pos.pallet_id=dp.warehouse_pallet_id " +
"JOIN ll_inventory_balance bal ON bal.id=pos.balance_id AND bal.customer_id=pos.customer_id " +
"WHERE dp.customer_id=c.customer_id AND dp.order_id=c.order_id AND dp.id=c.profile_id " +
"AND dp.status='ACTIVE' AND pos.status='OCCUPIED' AND pos.available_packages>0 " +
"AND bal.available_packages>0) ";
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@@ -50,10 +59,12 @@ public interface CargoDiscardInventoryMapper {
int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id")
/** 仅列出仍有实际可用库存的箱子,排除已出库托盘的残留箱唛。 */
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK + " ORDER BY c.profile_id,c.id")
List<DropshipCarton> availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 提交选箱时再次核对实际可用库存,避免旧页面绕过候选过滤。 */
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND c.id IN <foreach collection='cartonIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND EXISTS(SELECT 1 FROM ll_dropship_carton_sku cs WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) ORDER BY c.profile_id,c.id FOR UPDATE</script>")
List<DropshipCarton> lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@@ -72,6 +83,7 @@ public interface CargoDiscardInventoryMapper {
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
"COUNT(c.id) available_packages FROM ll_order o " +
@@ -80,33 +92,37 @@ public interface CargoDiscardInventoryMapper {
"JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
"AND c.profile_id=p.id AND c.status='AVAILABLE' " +
"JOIN ll_dropship_carton_sku cs ON cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=s.sku_code " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " + AVAILABLE_STOCK +
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
"p.id,s.id,s.sku_code,s.product_name HAVING COUNT(c.id)&gt;0 ORDER BY o.created_at DESC,o.id DESC,p.id,s.sku_code LIMIT 500</script>")
List<CargoDiscardCandidateRow> candidates(@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** SKU 可选数量仅统计实际仍在可用仓位的箱子。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 托盘 SKU 数量与候选及提交使用相同库存门禁。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.profile_id=#{profileId} AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 全部废弃锁箱时排除已出库或无实际可用库存的托盘。 */
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) " +
"ORDER BY c.profile_id,c.carton_sequence,c.id LIMIT #{limit} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("limit") int limit);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND status='AVAILABLE'")
/** 入库单按实际可用托盘统计物理箱数,不重复累计混装 SKU。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK)
long countAvailableOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.discard.model;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import java.util.HashSet;
import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities {
/** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { }
/** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */
public static int boxes(List<CargoDiscardItem> lines, boolean actual) {
var returnBoxes = new HashSet<Long>();
int total = 0;
for (var line : lines) {
int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages();
if (line.getReturnCartonId() != null) {
if (quantity > 0) returnBoxes.add(line.getReturnCartonId());
} else {
total = Math.addExact(total, quantity);
}
}
return Math.addExact(total, returnBoxes.size());
}
}
@@ -19,8 +19,9 @@ public final class PdaCargoDiscardDtos {
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
}
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status) { }
Integer actualPackages, String status, String quantityUnit) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
@@ -117,8 +117,8 @@ public class PdaCargoDiscardService {
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
if (items.listApplication(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream()
.mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum();
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
items.listApplication(application.getCustomerId(), application.getId()), true);
application.setTotalPackages(actualTotal);
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
@@ -160,16 +160,20 @@ public class PdaCargoDiscardService {
}
private static final String LOOSE = "散箱";
/** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
var allItems = items.listApplication(application.getCustomerId(), application.getId());
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱")).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardQuantities;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 从申请明细读取物理箱汇总,不修改历史库存或件数。 */
@Repository
public class CargoDiscardQuantityRepository {
@Resource private CargoDiscardItemMapper items;
/** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */
public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
}
}
@@ -39,7 +39,7 @@ public class CargoDiscardOutboundService {
return outbound == null ? null : outbound.getOutboundNo();
}
// The caller holds the application lock and owns the approval transaction.
/** 审核事务持有申请锁时创建出库投影,汇总箱数与退货件数分开保存。 */
public void create(CargoDiscardApplication application, String operatorId) {
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
var lines = items.listApplication(application.getCustomerId(), application.getId());
@@ -51,12 +51,13 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
var palletCodes = new java.util.HashSet<String>();
var returnBoxIds = new java.util.HashSet<Long>();
for (var line : lines) {
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
String palletNo;
@@ -83,11 +84,12 @@ public class CargoDiscardOutboundService {
}
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
OutboundPalletItem snapshot = new OutboundPalletItem();
// Stable one-to-one projection key in a separate table, never used to deduct stock.
// 出库明细与申请明细一一对应,仅作投影,不通过此表扣减库存。
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages());
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
snapshots.add(snapshot);
@@ -96,15 +98,21 @@ public class CargoDiscardOutboundService {
snapshots.forEach(pallets::insert);
}
/** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */
public void sync(CargoDiscardApplication application) {
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
var lines = items.listApplication(application.getCustomerId(), application.getId());
var returnBoxIds = new java.util.HashSet<Long>();
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
boolean returned = line.getReturnCartonId() != null;
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
snapshot.setPackageCount(boxes);
if ("COMPLETED".equals(line.getPdaStatus())) {
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setStatus("PICKED");
}
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
@@ -117,7 +125,7 @@ public class CargoDiscardOutboundService {
};
outbound.setStatus(status);
outbound.setLxCodes(null);
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -50,6 +50,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -138,6 +139,7 @@ public class CargoDiscardService {
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
}
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
CargoDiscardRequests.Create request) {
@@ -198,7 +200,8 @@ public class CargoDiscardService {
} else if (!warehouseId.equals(itemWarehouseId)) {
throw conflict("One cargo discard application cannot span operating warehouses");
}
total = Math.addExact(total, input.discardPackages());
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
if (!returnSource) total = Math.addExact(total, input.discardPackages());
}
for (Long cartonId : returnCartonCache.keySet()) {
@@ -209,6 +212,7 @@ public class CargoDiscardService {
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
}
total = Math.addExact(total, returnCartonCache.size());
LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication();
@@ -506,10 +510,12 @@ public class CargoDiscardService {
return names;
}
/** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
int boxes = quantities.boxes(value);
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
}
@@ -3,9 +3,7 @@ package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
@@ -22,34 +20,32 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
@Service
public class FleetPoolOrderBridge {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final FleetPoolOrderRepository repository;
private final FleetPoolOrderNumberGenerator numbers;
private final ObjectMapper json;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
/** 注入同库商业委托仓储及履约协作服务。 */
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.repository=repository;
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.appointmentSources=appointmentSources;
}
/** 批量读取预订对应的当前商业履约阶段。 */
@Transactional(readOnly=true)
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
if(bookingIds.isEmpty())return Map.of();
var result=new HashMap<Long,String>();
for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
for(var link:repository.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
return Map.copyOf(result);
}
/** 在承运确认事务内创建唯一商业订单,重放时核对来源哈希。 */
@Transactional(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in);
String hash=hash(in); var prior=links.lockBooking(in.bookingId());
String hash=hash(in); var prior=repository.lockBooking(in.bookingId());
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
@@ -62,51 +58,55 @@ public class FleetPoolOrderBridge {
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportMode("SHARED_TRUCK");order.setTransportType(transport);
// A pallet count/space is not a palletizing service request or a container specification.
order.setPalletRequired(null);order.setContainerType(null);
order.setTransportType(transport);
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
order.setPalletRequired(null);order.setProductName(in.cargoName());order.setEstimatedPalletCount(in.expectedPalletCount());
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount());
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());
order.setPickupAt(in.plannedDepartureAt());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order.
repository.insertLoose(order); // No placeholder ll_order: incoming owns the one physical tail order.
} else {
var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportType(transport);order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());
order.setContainerType(null); // Pool inquiry does not collect a container specification.
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());
order.setPalletCount(in.expectedPalletCount());order.setPackageCount(in.packageCount());order.setReceivedPalletCount(0);order.setAllocatedPalletCount(0);
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
// 来源未提供的车型、目的联系人与送货时间保持未知。
order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());order.setPickupAt(in.plannedDepartureAt());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
trucks.insert(order); // No simulated ll_us_truck_order_pallet rows.
repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows.
}
links.insert(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
return ref(row);
}
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
@Transactional(propagation=Propagation.MANDATORY)
public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId,
String warehouseId,String destinationKey,String operator) {
var row=links.lockBookingNo(bookingNo);
if(row==null){if(links.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
var row=repository.lockBookingNo(bookingNo);
if(row==null){if(repository.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
owned(row,customerId);
if(!Objects.equals(row.getWarehouseId(),warehouseId)||!Objects.equals(row.getDestinationKey(),destinationKey))throw conflict("来货仓库或目的地与池来源商业委托不一致");
if(row.getIncomingNo()!=null){if(!row.getIncomingNo().equals(incomingNo)||!Objects.equals(row.getLastlegOrderId(),lastlegOrderId))throw conflict("商业委托已绑定其他来货");return;}
if(!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("商业委托当前不能登记来货");
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());links.updateById(row);
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());repository.updateLink(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
var order=repository.lockLoose(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源");
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order);
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());repository.updateLoose(order);
}
appointmentSources.materialized(customerId, bookingNo, lastlegOrderId);
}
/** 按真实来货开始事件推进收货状态。 */
@Transactional(propagation=Propagation.MANDATORY)
public void receiving(String customerId,String incomingNo,String operator) {
var row=links.lockIncoming(customerId,incomingNo);if(row==null)return;
var row=repository.lockIncoming(customerId,incomingNo);if(row==null)return;
if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now());
}
/** 沿已确认实托及实占托位保存实际收货。 */
@Transactional(propagation=Propagation.MANDATORY)
public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId);
@@ -123,6 +123,7 @@ public class FleetPoolOrderBridge {
row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces);
move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now());
}
/** 沿真实排车关联推进商业履约。 */
@Transactional(propagation=Propagation.MANDATORY)
public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -130,6 +131,7 @@ public class FleetPoolOrderBridge {
if(!"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())||dispatchNo==null||dispatchNo.isBlank())throw conflict("商业单仅实收核量后可关联发运");
row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now());
}
/** 校验商业履约边界或组装真实业务引用。 */
@Transactional(propagation=Propagation.MANDATORY)
public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -137,6 +139,7 @@ public class FleetPoolOrderBridge {
if(!Objects.equals(row.getDispatchNo(),dispatchNo)||!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单当前不能解除发运关联");
row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now());
}
/** 校验商业履约边界或组装真实业务引用。 */
@Transactional(propagation=Propagation.MANDATORY)
public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -144,19 +147,21 @@ public class FleetPoolOrderBridge {
if(row.getIncomingNo()!=null||!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("池来源已登记来货,首期不支持取消或跨车改配");
move(row,"CANCELLED",operator,reason,LocalDateTime.now());
}
/** 沿真实整车发车事实推进运输状态。 */
@Transactional(propagation=Propagation.MANDATORY)
public void departed(String customerId,String dispatchNo,Map<Long,Integer> sourcePackages,String operator,LocalDateTime at) {
for(var row:links.lockDispatch(customerId,dispatchNo)) {
for(var row:repository.lockDispatch(customerId,dispatchNo)) {
if(row.getActualPackageCount()==null||!Objects.equals(sourcePackages.get(row.getLastlegOrderId()),row.getActualPackageCount()))throw conflict("商业单实际出库来源或完整货量不匹配");
if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue;
if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运");
move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at);
}
}
/** 核对全部签收证据后推进商业完成。 */
@Transactional(propagation=Propagation.MANDATORY)
public void signed(SignedDelivery delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.dispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
for(var row:repository.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue;
if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整");
if("COMPLETED".equals(row.getFulfillmentStage()))continue;
@@ -165,12 +170,14 @@ public class FleetPoolOrderBridge {
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at);
}
}
/** 校验商业履约边界或组装真实业务引用。 */
private boolean wholeSourceSigned(FleetPoolOrderLink row) {
return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount());
}
/** 批量读取预订的业务编号。 */
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
links.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
}
static void validate(ConfirmedBooking in) {
if(in==null||in.bookingId()<=0||in.businessType()==null||!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(in.businessType())
@@ -183,37 +190,47 @@ public class FleetPoolOrderBridge {
||("US_TRUCK".equals(in.businessType())&&(in.expectedPalletCount()==null||in.expectedPalletCount()<1)))
throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份");
}
/** 在同一事务更新商业阶段与不可变历史。 */
private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) {
String from=row.getFulfillmentStage();if(stage.equals(from))return;
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);links.updateById(row);
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);repository.updateLink(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);loose.updateById(order);
var order=repository.lockLoose(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);repository.updateLoose(order);
} else {
var order=trucks.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
var order=repository.lockTruck(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);
if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount());
if(row.getActualPalletCount()!=null)order.setAllocatedPalletCount(row.getDispatchNo()==null?0:row.getActualPalletCount());
trucks.updateById(order);
repository.updateTruck(order);
}
history(row,from,stage,operator,reason,at);
}
/** 保存来源业务号和履约事件的不可变记录。 */
private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) {
String key="FLEET_POOL:"+row.getBookingNo()+":"+to;
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var h=new LocalLooseboardHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);looseHistory.insert(h);
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertLooseHistory(h);
} else {
var h=new UsTruckOrderStatusHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);truckHistory.insert(h);
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertTruckHistory(h);
}
}
/** 校验商业履约边界或组装真实业务引用。 */
private String hash(Object input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(input).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private FleetPoolOrderLink requiredBooking(long bookingId){var row=links.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
/** 锁定并读取必需的商业委托。 */
private FleetPoolOrderLink requiredBooking(long bookingId){var row=repository.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
/** 校验商业履约边界或组装真实业务引用。 */
private static void touch(FleetPoolOrderLink row,LocalDateTime at){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedAt(at);}
/** 校验商业履约边界或组装真实业务引用。 */
private static BusinessOrderRef ref(FleetPoolOrderLink row){return new BusinessOrderRef(row.getBusinessType(),row.getBusinessOrderNo());}
/** 校验商业履约边界或组装真实业务引用。 */
private static void owned(FleetPoolOrderLink row,String customer){if(!Objects.equals(row.getCustomerId(),customer))throw conflict("商业委托客户归属不一致");}
/** 校验商业履约边界或组装真实业务引用。 */
private static boolean blank(String value){return value==null||value.isBlank();}
/** 校验商业履约边界或组装真实业务引用。 */
private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");}
/** 校验商业履约边界或组装真实业务引用。 */
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Collection;
@Repository
public class FleetPoolOrderRepository {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
/** 注入同库商业委托及历史 Mapper,事务由服务统一管理。 */
public FleetPoolOrderRepository(FleetPoolOrderMapper links, LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory, UsTruckOrderMapper trucks, UsTruckOrderStatusHistoryMapper truckHistory) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;this.truckHistory=truckHistory;
}
/** 批量读取预订关联。 */
public List<FleetPoolOrderLink> bookings(Collection<Long> ids) { return links.bookings(ids); }
/** 锁定预订关联。 */
public FleetPoolOrderLink lockBooking(long id) { return links.lockBooking(id); }
/** 按预订业务号锁定关联。 */
public FleetPoolOrderLink lockBookingNo(String number) { return links.lockBookingNo(number); }
/** 查询预订是否属于运力池。 */
public int poolBooking(String number) { return links.poolBooking(number); }
/** 按客户和来货号锁定关联。 */
public FleetPoolOrderLink lockIncoming(String customer, String number) { return links.lockIncoming(customer, number); }
/** 按客户和排车号锁定关联。 */
public List<FleetPoolOrderLink> lockDispatch(String customer, String number) { return links.lockDispatch(customer, number); }
/** 保存唯一商业关联。 */
public void insertLink(FleetPoolOrderLink row) { links.insert(row); }
/** 更新已锁定商业关联。 */
public void updateLink(FleetPoolOrderLink row) { links.updateById(row); }
/** 保存散板商业主单。 */
public void insertLoose(LocalLooseboardOrder order) { loose.insert(order); }
/** 锁定同客户散板主单。 */
public LocalLooseboardOrder lockLoose(String customer, String number) { return loose.lockNo(customer, number); }
/** 保存已锁定散板事实。 */
public void updateLoose(LocalLooseboardOrder order) { loose.updateById(order); }
/** 保存卡车商业主单。 */
public void insertTruck(UsTruckOrder order) { trucks.insert(order); }
/** 锁定同客户卡车主单。 */
public UsTruckOrder lockTruck(String customer, String number) { return trucks.lockNo(customer, number); }
/** 保存已锁定卡车事实。 */
public void updateTruck(UsTruckOrder order) { trucks.updateById(order); }
/** 保存散板履约历史。 */
public void insertLooseHistory(LocalLooseboardHistory history) { looseHistory.insert(history); }
/** 保存卡车履约历史。 */
public void insertTruckHistory(UsTruckOrderStatusHistory history) { truckHistory.insert(history); }
}
@@ -7,12 +7,14 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
/** 预约日历只读海外仓出库派送,取货入仓车次不属于排车类型。 */
@Select("""
SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,
expected_driver_end_at,departed_at,completed_at,stops_json
FROM ll_fleet_trip
WHERE (departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to})
WHERE trip_purpose='DELIVERY' AND (
(departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to}))
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001
""")
java.util.List<FleetTripEntity> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
@@ -10,49 +10,72 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import jakarta.validation.constraints.AssertTrue;
import com.fasterxml.jackson.annotation.JsonIgnore;
public final class LocalLooseboardRequests {
/** 仅作为散板请求或响应模型的命名空间。 */
private LocalLooseboardRequests() { }
public record Create(
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Size(max = 255) String productName,
@NotNull @Min(0) Integer estimatedPalletCount,
@NotNull @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Pattern(regexp = "USD|CNY") String currency,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@NotBlank @Size(max = 255) String idempotencyKey) {
/** 取送时间均须填写,送货不能早于取货。 */
@JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间")
public boolean isScheduleValid() {
return validSchedule(pickupAt, deliveryAt);
}
}
public record Update(
@NotNull @Min(0) Long version,
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Size(max = 255) String productName,
@NotNull @Min(0) Integer estimatedPalletCount,
@NotNull @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Size(max = 1000) String remark) { }
@Pattern(regexp = "USD|CNY") String currency,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark) {
/** 取送时间均须填写,送货不能早于取货。 */
@JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间")
public boolean isScheduleValid() {
return validSchedule(pickupAt, deliveryAt);
}
}
/** 统一校验平台及客户新增、编辑的需求时间。 */
public static boolean validSchedule(LocalDateTime pickup, LocalDateTime delivery) {
return pickup != null && delivery != null && !delivery.isBefore(pickup);
}
public record Query(
@Min(1) long page,
@@ -61,7 +84,6 @@ public final class LocalLooseboardRequests {
String orderNo,
String routeKeyword,
String status,
String transportMode,
LocalDate requestedFrom,
LocalDate requestedTo) { }
@@ -71,7 +93,6 @@ public final class LocalLooseboardRequests {
String orderNo,
String routeKeyword,
String status,
String transportMode,
LocalDate requestedFrom,
LocalDate requestedTo) { }
@@ -1,19 +1,19 @@
package com.baiyue.lastleg.looseboard.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class LocalLooseboardResponses {
/** 仅作为散板请求或响应模型的命名空间。 */
private LocalLooseboardResponses() { }
/** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */
/** 需求与内部安排分别返回;未知联系人、商品及时间不补造。 */
public record Summary(
Long id, String customerId, String orderNo, String transportMode, String palletRequired,
String transportType, String origin, String destination, String containerType, int packageCount,
Long id, String customerId, String orderNo, String palletRequired,
String transportType, String origin, String destination, String productName, Integer estimatedPalletCount, int packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, LocalDate requestedDate, String status, long version,
String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String status, long version,
LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) { }
public record History(
@@ -1,12 +1,13 @@
package com.baiyue.lastleg.looseboard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@@ -16,24 +17,27 @@ public class LocalLooseboardOrder {
private String customerId;
private Long lastlegOrderId;
private String orderNo;
private String transportMode;
private String palletRequired;
private String transportType;
private String origin;
private String originAddress;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String originAddress;
private String destination;
private String destinationAddress;
private String containerType;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String destinationAddress;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productName;
private Integer estimatedPalletCount;
private Integer packageCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal budgetAmount;
private String currency;
private String contactName;
private String contactInfo;
private LocalDate requestedDate;
private String originContactName;
private String originContactPhone;
private String destinationContactName;
private String destinationContactPhone;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime pickupAt;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime deliveryAt;
private String status;
private String remark;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.looseboard.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import org.springframework.util.StringUtils;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class LocalLooseboardRepository {
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
/** 按客户和创建幂等键读取订单。 */
public LocalLooseboardOrder findKey(String customer, String key) { return orders.findKey(customer, key); }
/** 按客户和业务单号读取订单。 */
public LocalLooseboardOrder findNo(String customer, String number) { return orders.findNo(customer, number); }
/** 锁定同客户订单保护编辑及状态推进。 */
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay())
.lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
return orders.selectPage(Page.of(query.page(), query.size()), wrapper);
}
/** 规范查询文字,缺失值不参与筛选。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 保存散板需求。 */
public void insertOrder(LocalLooseboardOrder order) { orders.insert(order); }
/** 更新已锁定需求。 */
public void updateOrder(LocalLooseboardOrder order) { orders.updateById(order); }
/** 读取同客户订单历史。 */
public List<LocalLooseboardHistory> histories(String customer, Long id) { return histories.listOrder(customer, id); }
/** 保存不可变需求历史。 */
public void insertHistory(LocalLooseboardHistory history) { histories.insert(history); }
/** 读取状态命令幂等记录。 */
public LocalLooseboardOperation findOperation(String customer, Long id, String key) { return operations.findKey(customer, id, key); }
/** 保存命令幂等记录。 */
public void insertOperation(LocalLooseboardOperation operation) { operations.insert(operation); }
/** 保存唯一尾程来源。 */
public void insertTailOrder(LastlegOrder order) { lastlegOrders.insert(order); }
/** 读取关联尾程来源。 */
public LastlegOrder tailOrder(Long id) { return lastlegOrders.selectById(id); }
/** 更新关联尾程事实。 */
public void updateTailOrder(LastlegOrder order) { lastlegOrders.updateById(order); }
/** 保存尾程状态历史。 */
public void insertTailHistory(LastlegOrderStatusHistory history) { lastlegHistories.insert(history); }
}
@@ -1,23 +1,17 @@
package com.baiyue.lastleg.looseboard.service;
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -48,36 +42,32 @@ public class LocalLooseboardService {
public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
private static final Set<String> MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
IN_TRANSIT, Set.of(COMPLETED));
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
@Resource private LocalLooseboardRepository repository;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@Resource private ObjectMapper objectMapper;
/** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */
@Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOrder replay = orders.findKey(customerId, key);
LocalLooseboardOrder replay = repository.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, requestHash);
LocalDateTime now = LocalDateTime.now();
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId()));
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
order.setStatus(PENDING_ACCEPTANCE);
order.setIdempotencyKey(key);
order.setRequestHash(requestHash);
@@ -88,9 +78,9 @@ public class LocalLooseboardService {
LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now);
order.setLastlegOrderId(lastlegOrder.getId());
try {
orders.insert(order);
repository.insertOrder(order);
} catch (DuplicateKeyException exception) {
LocalLooseboardOrder concurrent = orders.findKey(customerId, key);
LocalLooseboardOrder concurrent = repository.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
@@ -101,44 +91,34 @@ public class LocalLooseboardService {
return detail(customerId, order.getOrderNo());
}
/** 按客户、状态及取货日期分页查询需求,并批量装配承运摘要。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
.eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode,
StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom())
.le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
Page<LocalLooseboardOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
Page<LocalLooseboardOrder> result = repository.page(query);
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
/** Customer-scoped query that ignores any client-supplied customer filter. */
/** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(),
query.orderNo(), query.routeKeyword(), query.status(),
query.requestedFrom(), query.requestedTo()));
}
/** 按客户与业务单号读取需求及历史。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
LocalLooseboardOrder order = repository.findNo(customerId, orderNo);
if (order == null) throw notFound();
return response(order);
}
/** 锁定待接单需求并校验版本,保存变更前后快照。 */
@Transactional
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) {
@@ -148,27 +128,28 @@ public class LocalLooseboardService {
throw conflict("Only pending-acceptance looseboard orders can be edited");
}
Map<String, Object> before = snapshot(order);
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
Map<String, Object> after = snapshot(order);
if (before.equals(after)) return response(order);
LocalDateTime now = LocalDateTime.now();
touch(order, now);
orders.updateById(order);
repository.updateOrder(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
null, changeDetail(before, after), null, now);
return response(order);
}
/** 按版本和独立幂等键推进允许的状态,同时记录不可变历史。 */
@Transactional
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key);
LocalLooseboardOperation existing = repository.findOperation(customerId, order.getId(), key);
if (existing != null) {
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency key was reused with a different looseboard status payload");
@@ -186,81 +167,95 @@ public class LocalLooseboardService {
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
touch(order, now);
orders.updateById(order);
repository.updateOrder(order);
syncLastlegOrder(order, next, now);
LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId);
operation.setCreatedAt(now); operations.insert(operation);
operation.setCreatedAt(now); repository.insertOperation(operation);
history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
return response(order);
}
private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType,
/** 保存申报货物与当地取送时间,不预设内部安排或实际收货。 */
private void apply(LocalLooseboardOrder order,
String origin, String originAddress, String destination, String destinationAddress,
String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg,
String productName, Integer estimatedPalletCount, Integer packageCount, java.math.BigDecimal totalWeightKg,
java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) {
order.setTransportMode(norm(mode));
order.setPalletRequired(norm(palletRequired));
order.setTransportType(norm(transportType));
String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String remark) {
if (!StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone)
|| !StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) {
throw badRequest("请填写起运地和目的地联系人及联系电话");
}
if (!LocalLooseboardRequests.validSchedule(pickupAt, deliveryAt)) {
throw badRequest("取货和送货时间必填,送货时间不能早于取货时间");
}
order.setProductName(trim(productName));
order.setEstimatedPalletCount(estimatedPalletCount);
order.setOrigin(origin.trim());
order.setOriginAddress(trim(originAddress));
order.setDestination(destination.trim());
order.setDestinationAddress(trim(destinationAddress));
order.setContainerType(StringUtils.hasText(containerType)
? ContainerSpecificationCatalog.require(containerType).containerType()
: ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE);
order.setPackageCount(packageCount);
order.setTotalWeightKg(totalWeightKg);
order.setTotalVolumeCbm(totalVolumeCbm);
order.setBudgetAmount(budgetAmount);
order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD");
order.setContactName(trim(contactName));
order.setContactInfo(trim(contactInfo));
order.setRequestedDate(requestedDate);
order.setOriginContactName(originContactName.trim());
order.setOriginContactPhone(originContactPhone.trim());
order.setDestinationContactName(destinationContactName.trim());
order.setDestinationContactPhone(destinationContactPhone.trim());
order.setPickupAt(pickupAt);
order.setDeliveryAt(deliveryAt);
order.setRemark(trim(remark));
}
/** 拒绝幂等键绑定不同内容,重复请求返回原需求。 */
private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) {
if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data");
return detail(customerId, order.getOrderNo());
}
/** 锁定客户订单并拒绝池来源的普通编辑。 */
private LocalLooseboardOrder lock(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo);
LocalLooseboardOrder order = repository.lockNo(customerId, orderNo);
if (order == null) throw notFound();
fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId());
return order;
}
/** 装配需求详情、真实承运安排与不可变历史。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
histories.listOrder(order.getCustomerId(), order.getId()).stream()
repository.histories(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
/** 构造同客户商业订单的承运查询身份。 */
private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(LocalLooseboardOrder order) {
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
}
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
return summary(order, fleetPool.arrangement(poolKey(order)));
}
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(),
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(),
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
}
/** 写入带操作者和变更快照的订单历史。 */
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
LocalLooseboardHistory history = new LocalLooseboardHistory();
@@ -275,9 +270,10 @@ public class LocalLooseboardService {
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
histories.insert(history);
repository.insertHistory(history);
}
/** 为独立下单创建唯一尾程来源,不按预计托数生成实物托盘。 */
private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
@@ -302,21 +298,23 @@ public class LocalLooseboardService {
order.setSourcePayloadHash("sha256:" + requestHash);
order.setCreatedAt(now);
order.setUpdatedAt(now);
lastlegOrders.insert(order);
repository.insertTailOrder(order);
return order;
}
/** 在同客户边界内同步关联尾程的商业状态。 */
private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) {
if (source.getLastlegOrderId() == null) return;
LastlegOrder order = lastlegOrders.selectById(source.getLastlegOrderId());
LastlegOrder order = repository.tailOrder(source.getLastlegOrderId());
if (order == null || !source.getCustomerId().equals(order.getCustomerId())) {
throw conflict("Linked tail-leg order is missing");
}
order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status);
order.setUpdatedAt(now);
lastlegOrders.updateById(order);
repository.updateTailOrder(order);
}
/** 追加尾程状态历史。 */
private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark,
LocalDateTime now) {
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
@@ -327,44 +325,47 @@ public class LocalLooseboardService {
history.setEventId("LOCAL_LOOSEBOARD");
history.setRemark(remark);
history.setCreatedAt(now);
lastlegHistories.insert(history);
repository.insertTailHistory(history);
}
/** 校验状态与取货日期筛选边界。 */
private void validateQuery(LocalLooseboardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
throw badRequest("Unsupported looseboard order status");
}
if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) {
throw badRequest("Unsupported looseboard transport mode");
}
if (query.requestedFrom() != null && query.requestedTo() != null
&& query.requestedFrom().isAfter(query.requestedTo())) {
throw badRequest("requestedFrom must not be after requestedTo");
}
}
/** 冻结本次编辑的业务字段用于差异审计。 */
private Map<String, Object> snapshot(LocalLooseboardOrder order) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("transportMode", order.getTransportMode());
value.put("palletRequired", order.getPalletRequired());
value.put("transportType", order.getTransportType());
value.put("origin", order.getOrigin());
value.put("originAddress", order.getOriginAddress());
value.put("destination", order.getDestination());
value.put("destinationAddress", order.getDestinationAddress());
value.put("containerType", order.getContainerType());
value.put("productName", order.getProductName());
value.put("estimatedPalletCount", order.getEstimatedPalletCount());
value.put("packageCount", order.getPackageCount());
value.put("totalWeightKg", order.getTotalWeightKg());
value.put("totalVolumeCbm", order.getTotalVolumeCbm());
value.put("budgetAmount", order.getBudgetAmount());
value.put("currency", order.getCurrency());
value.put("contactName", order.getContactName());
value.put("contactInfo", order.getContactInfo());
value.put("requestedDate", order.getRequestedDate());
value.put("originContactName", order.getOriginContactName());
value.put("originContactPhone", order.getOriginContactPhone());
value.put("destinationContactName", order.getDestinationContactName());
value.put("destinationContactPhone", order.getDestinationContactPhone());
value.put("pickupAt", order.getPickupAt());
value.put("deliveryAt", order.getDeliveryAt());
value.put("remark", order.getRemark());
return value;
}
/** 生成字段前后值的不可变变更快照。 */
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> {
@@ -383,6 +384,7 @@ public class LocalLooseboardService {
}
}
/** 计算规范请求哈希以约束创建和状态命令幂等。 */
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -392,20 +394,27 @@ public class LocalLooseboardService {
}
}
/** 递增锁定订单的版本并更新时间。 */
private static void touch(LocalLooseboardOrder order, LocalDateTime now) {
order.setVersion(value(order.getVersion()) + 1);
order.setUpdatedAt(now);
}
/** 将未初始化的版本视作初始版本。 */
private static long value(Long value) { return value == null ? 0 : value; }
/** 规范选填文字,空白保存为未知。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 统一内部枚举和币种大小写。 */
private static String norm(String value) { return value.trim().toUpperCase(); }
/** 返回明确的输入校验失败。 */
private static ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
/** 返回版本、幂等或状态冲突。 */
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
/** 隐藏客户范围外订单并返回未找到。 */
private static ResponseStatusException notFound() {
return new ResponseStatusException(HttpStatus.NOT_FOUND, "本土散板订单不存在或无权查看");
}
@@ -6,6 +6,16 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
/** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */
@org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT i.return_carton_id FROM ll_relabel_item i " +
"JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " +
"WHERE i.customer_id=#{customerId} AND i.return_carton_id IN " +
"<foreach collection='cartonIds' item='cartonId' open='(' separator=',' close=')'>#{cartonId}</foreach> " +
"AND a.status NOT IN ('CANCELLED','REJECTED') AND a.customer_confirmed_at IS NOT NULL " +
"AND EXISTS(SELECT 1 FROM ll_relabel_pda_unit_fact f WHERE f.item_id=i.id AND f.result='RELABELLED')</script>")
java.util.List<Long> completedReturnCartonIds(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("cartonIds") java.util.List<Long> cartonIds);
@org.apache.ibatis.annotations.Select("SELECT ri.* FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
"WHERE ri.customer_id=#{customerId} AND ri.return_carton_id=#{cartonId} AND ri.application_id<>#{currentApplicationId} " +
"AND ra.status NOT IN ('CANCELLED','REJECTED') AND ra.customer_confirmed_at IS NOT NULL " +
@@ -5,11 +5,22 @@ import org.apache.ibatis.annotations.Select;
@Mapper public interface InboundSnapshotMapper {
record SourceCarton(String cartonMark, String skuCode, String productNameZh, Long quantity,
String destinationWarehouseCode, String destinationWarehouseName, String skuItemsJson) {}
/** 查询来源单当前仍可转运的箱级明细,排除已经存在有效换标记录的原箱。 */
@Select("""
SELECT c.carton_mark cartonMark,c.sku_code skuCode,NULL productNameZh,NULL quantity,
c.destination_warehouse_code destinationWarehouseCode,c.destination_warehouse_name destinationWarehouseName,NULL skuItemsJson
FROM ll_inbound_plan_carton c JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND o.overseas_warehouse_id=#{warehouseId}
AND NOT EXISTS (
SELECT 1 FROM ll_relabel_item ri
JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id
WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code
AND ra.overseas_warehouse_id=#{warehouseId}
AND UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark))
AND ra.status NOT IN ('CANCELLED','REJECTED')
)
UNION ALL
SELECT c.carton_mark,i.sku_code,i.product_name_zh,c.counted_quantity,
t.destination_warehouse_code,t.destination_warehouse_name,c.sku_items_json
@@ -18,6 +29,17 @@ import org.apache.ibatis.annotations.Select;
JOIN ll_order o ON o.id=t.lastleg_order_id AND o.customer_id=t.customer_id
JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND i.transfer_order_id=t.id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND t.operating_warehouse_id=#{warehouseId} AND c.status='COUNTED'
AND NOT EXISTS (
SELECT 1 FROM ll_relabel_item ri
JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id
WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code
AND ra.overseas_warehouse_id=#{warehouseId}
AND (UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark))
OR UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(NULLIF(c.customer_carton_mark,''))))
AND ra.status NOT IN ('CANCELLED','REJECTED')
)
ORDER BY cartonMark
""")
java.util.List<SourceCarton> sourceCartons(@Param("customerId") String customerId,
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.transfer.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** 使用提交单据的原箱快照表达业务占用,不提前扣减实物库存。 */
@Mapper
public interface TransferSourceReservationMapper {
/** 按同一入仓快照串行提交,防止不同草稿同时抢占原箱。 */
@Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND id=#{inboundOrderId} FOR UPDATE")
Long lockInbound(@Param("customerId") String customerId, @Param("inboundOrderId") Long inboundOrderId);
/** 读取已提交转运的原箱;驳回、取消和草稿不占用,可重新选择。 */
@Select("SELECT COALESCE(NULLIF(c.customer_carton_mark,''), c.carton_mark) FROM ll_warehouse_transfer_carton c "
+ "JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id "
+ "WHERE t.customer_id=#{customerId} AND t.operating_warehouse_id=#{warehouseId} "
+ "AND t.inbound_code=#{sourceCode} AND t.transfer_no <> t.inbound_code "
+ "AND t.status NOT IN ('DRAFT','REJECTED','CANCELLED') "
+ "AND COALESCE(NULLIF(c.customer_carton_mark,''), c.carton_mark) IS NOT NULL")
List<String> occupiedMarks(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId, @Param("sourceCode") String sourceCode);
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.transfer.repository;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 封装库存转运来源箱查询,SQL和客户隔离由Mapper统一执行。 */
@Repository
public class TransferInventorySourceRepository {
@Resource private InboundSnapshotMapper snapshots;
@Resource private com.baiyue.lastleg.transfer.mapper.TransferSourceReservationMapper reservations;
/** 同一来源入仓单的所有库存转运先取相同的行锁。 */
public Long lockSource(String customerId, Long inboundOrderId) {
return reservations.lockInbound(customerId, inboundOrderId);
}
/** 读取当前客户及操作仓中已提交转运的原箱快照。 */
public List<String> occupiedMarks(String customerId, String warehouseId, String sourceCode) {
return reservations.occupiedMarks(customerId, warehouseId, sourceCode);
}
/** 读取当前来源单未被有效换标申请使用的箱级快照。 */
public List<InboundSnapshotMapper.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
return snapshots.sourceCartons(customerId, warehouseId, inboundCode);
}
}
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -17,6 +18,7 @@ public class TransferReturnSourceRepository {
@Resource private ReturnOrderMapper orders;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper items;
@Resource private RelabelItemMapper relabelItems;
/** 按当前客户和退货单号查询来源单据。 */
public ReturnOrder findOrder(String customerId, String returnNo) {
@@ -25,10 +27,15 @@ public class TransferReturnSourceRepository {
/** 查询当前仓内已完成清点且可用的退货箱,不影响其他页面的历史库存查询。 */
public List<ReturnInventoryCarton> availableCartons(String customerId, String warehouseId, String returnNo) {
return cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream()
var available = cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream()
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus())
&& carton.getRelabelApplicationId() == null)
.toList();
if (available.isEmpty()) return available;
// 已完成换标的箱虽然释放了当前占用字段,但不能再次作为原箱来源转运。
var completed = new java.util.HashSet<>(relabelItems.completedReturnCartonIds(customerId,
available.stream().map(ReturnInventoryCarton::getId).toList()));
return available.stream().filter(carton -> !completed.contains(carton.getId())).toList();
}
/** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */
@@ -149,6 +149,7 @@ public class WarehouseTransferService {
@Resource private InventoryCommandMapper inventoryCommands;
@Resource private ObjectMapper objectMapper;
@Resource private TransferReturnSourceRepository returnSources;
@Resource private com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository inventorySources;
/** 查询来源单据当前可用的箱唛及箱内 SKU,兼容普通入仓与退货库存。 */
public List<WarehouseTransferResponses.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
@@ -164,22 +165,38 @@ public class WarehouseTransferService {
List<Long> cartonIds = cartons.stream().map(ReturnInventoryCarton::getId).filter(Objects::nonNull).toList();
Map<Long, List<ReturnInventoryCartonItem>> items = cartonIds.isEmpty() ? Map.of()
: returnSources.cartonItems(cartonIds).stream().collect(Collectors.groupingBy(ReturnInventoryCartonItem::getCartonId));
return cartons.stream().map(carton -> {
return unreservedSourceCartons(customerId, warehouseId.trim(), source, cartons.stream().map(carton -> {
List<ReturnInventoryCartonItem> rows = items.getOrDefault(carton.getId(), List.of());
List<WarehouseTransferResponses.SourceCartonSku> contents = rows.stream()
.filter(item -> item.getQuantity() != null && item.getQuantity() > 0)
.map(item -> new WarehouseTransferResponses.SourceCartonSku(item.getSkuCode(), item.getQuantity())).toList();
return new WarehouseTransferResponses.SourceCarton(carton.getCartonMark(), null, carton.getTotalQuantity(),
carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), contents);
}).toList();
}).toList());
}
return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source).stream().map(row -> {
var sourceRows = inventorySources != null
? inventorySources.sourceCartons(customerId, warehouseId.trim(), source)
: inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source);
return unreservedSourceCartons(customerId, warehouseId.trim(), source, sourceRows.stream().map(row -> {
var carton = new WarehouseTransferCarton(); carton.setSkuItemsJson(row.skuItemsJson());
var contents = cartonContents(carton).stream().map(item -> new WarehouseTransferResponses.SourceCartonSku(item.skuCode(),item.quantity())).toList();
if (contents.isEmpty()) contents = List.of(new WarehouseTransferResponses.SourceCartonSku(row.skuCode(),row.quantity()));
return new WarehouseTransferResponses.SourceCarton(row.cartonMark(),row.productNameZh(),row.quantity(),
row.destinationWarehouseCode(),row.destinationWarehouseName(),contents);
}).toList();
}).toList());
}
/** 待审核即按原箱快照占用;所有来源查询复用此过滤,避免重复提交同一实物箱。 */
private List<WarehouseTransferResponses.SourceCarton> unreservedSourceCartons(String customerId,
String warehouseId, String source, List<WarehouseTransferResponses.SourceCarton> cartons) {
if (inventorySources == null) return cartons;
var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source);
if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons;
Set<String> occupied = occupiedMarks.stream()
.filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toSet());
return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim()
.toUpperCase(java.util.Locale.ROOT))).toList();
}
private static LocalDateTime arrivalTime(WarehouseTransferRequests.Create request) {
return StringUtils.hasText(request.inboundCode()) ? null : request.estimatedArrivalAt();
@@ -334,7 +351,7 @@ public class WarehouseTransferService {
}
/** Platform-created transfers keep the existing create-and-submit behavior. */
@Transactional
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public WarehouseTransferResponses.Detail createAndSubmit(String customerId, String operatorId,
WarehouseTransferRequests.Create request) {
WarehouseTransferResponses.Detail draft = create(customerId, operatorId, request);
@@ -456,7 +473,7 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
@Transactional
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
/** 提交草稿前重新校验来源箱归属,防止草稿保存后库存已发生变化。 */
public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
@@ -464,6 +481,12 @@ public class WarehouseTransferService {
if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo);
if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted");
if (!transfer.getTransferNo().equals(transfer.getInboundCode())) {
// 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。
if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null)
throw conflict("库存来源已失效,请重新选择来源单");
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
Set<String> allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode())
? sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())
@@ -477,6 +500,11 @@ public class WarehouseTransferService {
}
}
// 来源箱归属校验通过后再写入占用快照,保证错误箱号返回明确提示。
if (!transfer.getTransferNo().equals(transfer.getInboundCode()) && inventorySources != null) {
reserveSourceCartons(customerId, transfer);
}
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
transfer.setStatus(PENDING_REVIEW);
@@ -500,6 +528,29 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
/** 提交时逐箱核对来源快照并记录占用,禁止并发提交时静默替换用户选择。 */
private void reserveSourceCartons(String customerId, WarehouseTransferOrder transfer) {
var available = new java.util.LinkedHashMap<String, String>();
for (var box : sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())) {
available.put(box.cartonMark().trim().toUpperCase(java.util.Locale.ROOT), box.cartonMark());
}
var selected = transferCartons.listOrder(customerId, transfer.getId());
if (selected.size() != transfer.getPackageCount() || selected.isEmpty())
throw conflict("转运箱数与来源箱明细不一致,请刷新后重新提交");
var reserved = new java.util.LinkedHashMap<Long, String>();
for (var carton : selected) {
String mark = StringUtils.hasText(carton.getCustomerCartonMark())
? carton.getCustomerCartonMark() : carton.getCartonMark();
if (!StringUtils.hasText(mark)) throw conflict("转运箱缺少来源箱号,请刷新来源单");
String canonical = available.remove(mark.trim().toUpperCase(java.util.Locale.ROOT));
if (canonical == null) throw conflict("来源箱已被提交或不可用,请刷新来源单:" + mark);
reserved.put(carton.getId(), canonical);
}
for (var carton : selected) {
carton.setCustomerCartonMark(reserved.get(carton.getId()));
transferCartons.updateById(carton);
}
}
@Transactional(readOnly = true)
public PageResult<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query) {
return page(customerId, query, true);
@@ -11,75 +11,73 @@ import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
public final class UsTruckOrderRequests {
/** 仅提供请求模型。 */
private UsTruckOrderRequests() {}
/** 客户或平台提交独立本土卡车需求,时间按填写值保存。 */
public record Create(
@Size(max = 32) String transportType,
@NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@NotNull @Min(1) Integer palletCount,
@Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 128) String contactInfo,
@NotNull LocalDate requestedDate,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) {
/** Backward-compatible constructor for clients still sending legacy transport fields. */
public Create(String transportType, String origin, String originAddress, String destination,
String destinationAddress, String containerType, Integer packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate,
String remark, String idempotencyKey) {
this(transportType, origin, originAddress, destination, destinationAddress, containerType,
packageCount == null || packageCount < 1 ? 1 : packageCount, packageCount,
totalWeightKg, totalVolumeCbm, budgetAmount, currency, contactName, contactInfo,
requestedDate, remark, idempotencyKey);
}
}
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record Query(
@Min(1) long page, @Min(1) @Max(200) long size,
String orderNo, String routeKeyword, String status, String transportType,
String customerId, LocalDate requestedFrom, LocalDate requestedTo) {}
String orderNo, String routeKeyword, String status, String vehicleType,
String customerId, @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupFrom,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupTo) {}
public record MerchantQuery(
@Min(1) long page, @Min(1) @Max(200) long size,
String orderNo, String routeKeyword, String status, String transportType,
LocalDate requestedFrom, LocalDate requestedTo) {
String orderNo, String routeKeyword, String status, String vehicleType,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupFrom,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupTo) {
/** 客户范围由认证身份决定,查询模型不接收客户标识。 */
public Query scoped() {
return new Query(page, size, orderNo, routeKeyword, status, transportType,
null, requestedFrom, requestedTo);
return new Query(page, size, orderNo, routeKeyword, status, vehicleType,
null, pickupFrom, pickupTo);
}
}
/** 待审核订单可更新需求;两端联系信息和时间须完整填写。 */
public record Update(
@NotNull @Min(0) Long version,
@Size(max = 32) String transportType,
@NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType,
@Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Min(1) Integer palletCount,
@Min(0) Integer packageCount,
@DecimalMin("0.00") BigDecimal totalWeightKg,
@DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark) {}
public record ChangeStatus(
@@ -1,20 +1,21 @@
package com.baiyue.lastleg.trucking.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class UsTruckOrderResponses {
/** 仅提供响应模型。 */
private UsTruckOrderResponses() {}
/** Pool orders retain null containerType; forecast physical pallets do not imply a container type. */
/** 独立订单展示客户填写的车型和两端信息;拼车来源未知字段保持为空。 */
public record Summary(
Long id, String customerId, String orderNo, String transportType,
String origin, String destination, String containerType, int palletCount, int packageCount,
Long id, String customerId, String orderNo, String vehicleType,
String origin, String destination, int palletCount, int packageCount,
int receivedPalletCount, int allocatedPalletCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate,
String currency, String originContactName, String originContactPhone,
String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) {}
public record History(
@@ -6,7 +6,6 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@@ -15,12 +14,11 @@ public class UsTruckOrder {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String orderNo;
private String transportType;
private String vehicleType;
private String origin;
private String originAddress;
private String destination;
private String destinationAddress;
private String containerType;
private Integer palletCount;
private Integer packageCount;
private Integer receivedPalletCount;
@@ -29,9 +27,12 @@ public class UsTruckOrder {
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
private String currency;
private String contactName;
private String contactInfo;
private LocalDate requestedDate;
private String originContactName;
private String originContactPhone;
private String destinationContactName;
private String destinationContactPhone;
private LocalDateTime pickupAt;
private LocalDateTime deliveryAt;
private String status;
private String remark;
private String idempotencyKey;
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.trucking.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
/** 卡车订单持久化边界;事务和状态决策由服务负责。 */
@Repository
public class UsTruckOrderRepository {
private final UsTruckOrderMapper orders;
private final UsTruckOrderPalletMapper pallets;
private final UsTruckOrderStatusHistoryMapper histories;
private final UsTruckOrderOperationMapper operations;
/** 绑定本服务的订单、托盘和不可变历史 Mapper。 */
public UsTruckOrderRepository(UsTruckOrderMapper orders, UsTruckOrderPalletMapper pallets,
UsTruckOrderStatusHistoryMapper histories, UsTruckOrderOperationMapper operations) {
this.orders = orders; this.pallets = pallets; this.histories = histories; this.operations = operations;
}
/** 按客户、路线、状态、车型和取货时间组合分页条件。 */
public Page<UsTruckOrder> page(String customerId, UsTruckOrderRequests.Query query) {
LambdaQueryWrapper<UsTruckOrder> w = new LambdaQueryWrapper<UsTruckOrder>()
.eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo())
.and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword())
.or().like(UsTruckOrder::getDestination, query.routeKeyword()))
.eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus,
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.eq(StringUtils.hasText(query.vehicleType()), UsTruckOrder::getVehicleType,
StringUtils.hasText(query.vehicleType()) ? query.vehicleType() : null)
.eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId())
.ge(query.pickupFrom() != null, UsTruckOrder::getPickupAt, query.pickupFrom())
.le(query.pickupTo() != null, UsTruckOrder::getPickupAt, query.pickupTo())
.orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId);
return orders.selectPage(Page.of(query.page(), query.size()), w);
}
/** 按客户和订单号读取。 */
public UsTruckOrder findNo(String customerId, String orderNo) { return orders.findNo(customerId, orderNo); }
/** 在当前事务中按客户和订单号加行锁。 */
public UsTruckOrder lockNo(String customerId, String orderNo) { return orders.lockNo(customerId, orderNo); }
/** 按客户和创建幂等键读取。 */
public UsTruckOrder findKey(String customerId, String key) { return orders.findKey(customerId, key); }
/** 保存新订单。 */
public void insertOrder(UsTruckOrder order) { orders.insert(order); }
/** 保存已锁定订单的当前状态。 */
public void updateOrder(UsTruckOrder order) { orders.updateById(order); }
/** 读取本客户订单的托盘。 */
public List<UsTruckOrderPallet> pallets(String customerId, Long orderId) { return pallets.listOrder(customerId, orderId); }
/** 保存实际托盘记录。 */
public void insertPallet(UsTruckOrderPallet row) { pallets.insert(row); }
/** 更新实际托盘处理状态。 */
public void updatePallet(UsTruckOrderPallet row) { pallets.updateById(row); }
/** 读取本客户订单的不可变历史。 */
public List<UsTruckOrderStatusHistory> histories(String customerId, Long orderId) { return histories.listOrder(customerId, orderId); }
/** 保存订单历史。 */
public void insertHistory(UsTruckOrderStatusHistory row) { histories.insert(row); }
/** 按客户、订单和幂等键读取操作结果。 */
public UsTruckOrderOperation findOperation(String customerId, Long orderId, String key) { return operations.findKey(customerId, orderId, key); }
/** 保存不可变操作摘要。 */
public void insertOperation(UsTruckOrderOperation row) { operations.insert(row); }
}
@@ -1,20 +1,14 @@
package com.baiyue.lastleg.trucking.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -26,12 +20,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
@@ -44,8 +35,6 @@ public class UsTruckOrderService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
/** Kept for source compatibility; new orders use PENDING_REVIEW. */
@Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW;
public static final String WAITING_INBOUND = "WAITING_INBOUND";
public static final String RECEIVING = "RECEIVING";
public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION";
@@ -63,43 +52,41 @@ public class UsTruckOrderService {
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH);
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
private static final Set<String> VEHICLE_TYPES = Set.of("PALLET_15", "PALLET_30");
@Resource private UsTruckOrderMapper orders;
@Resource private UsTruckOrderOperationMapper operations;
@Resource private UsTruckOrderPalletMapper pallets;
@Resource private UsTruckOrderStatusHistoryMapper histories;
@Resource private com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository repository;
@Resource private ObjectMapper objectMapper;
/** 创建卡车需求,校验两端资料并保留创建幂等结果。 */
@Transactional
public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) {
String key = request.idempotencyKey().trim();
String hash = hash(request);
UsTruckOrder replay = orders.findKey(customerId, key);
UsTruckOrder replay = repository.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, hash);
if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType());
validateRequirements(request.vehicleType(), request.originContactName(), request.originContactPhone(),
request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt());
LocalDateTime now = LocalDateTime.now();
UsTruckOrder order = new UsTruckOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo(orderNoGenerator.next("TRUCK", request.origin(), order.getId()));
order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null);
order.setVehicleType(request.vehicleType());
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
order.setContainerType(StringUtils.hasText(request.containerType())
? ContainerSpecificationCatalog.require(request.containerType()).containerType()
: ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE);
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
// 车型只表达用车需求;实际托数由收货记录,不能按车型虚构货量。
order.setPalletCount(0); order.setPackageCount(value(request.packageCount()));
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW);
order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim());
order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().trim());
order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); order.setStatus(PENDING_REVIEW);
order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash);
order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now);
try { orders.insert(order); }
try { repository.insertOrder(order); }
catch (DuplicateKeyException e) {
UsTruckOrder concurrent = orders.findKey(customerId, key);
UsTruckOrder concurrent = repository.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, hash);
throw e;
}
@@ -107,57 +94,48 @@ public class UsTruckOrderService {
return detail(customerId, order.getOrderNo());
}
/** 按客户范围分页读取订单并批量关联承运安排。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<UsTruckOrder> w = new LambdaQueryWrapper<UsTruckOrder>()
.eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo())
.and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword())
.or().like(UsTruckOrder::getDestination, query.routeKeyword()))
.eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus,
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
.eq(StringUtils.hasText(query.transportType()), UsTruckOrder::getTransportType,
StringUtils.hasText(query.transportType()) ? norm(query.transportType()) : null)
.eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId())
.ge(query.requestedFrom() != null, UsTruckOrder::getRequestedDate, query.requestedFrom())
.le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo())
.orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId);
Page<UsTruckOrder> result = orders.selectPage(Page.of(query.page(), query.size()), w);
Page<UsTruckOrder> result = repository.page(customerId, query);
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
/** 读取本客户的订单、实际托盘及操作历史。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) {
UsTruckOrder order = orders.findNo(customerId, orderNo);
UsTruckOrder order = repository.findNo(customerId, orderNo);
if (order == null) throw notFound();
return detail(order, histories.listOrder(customerId, order.getId()));
return detail(order, repository.histories(customerId, order.getId()));
}
/** 按版本更新待审核订单的车型、联系人和取送时间。 */
@Transactional
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Update request) {
UsTruckOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited");
if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); }
validateRequirements(request.vehicleType(), request.originContactName(), request.originContactPhone(),
request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt());
order.setVehicleType(request.vehicleType());
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
setText(request.containerType(), v -> order.setContainerType(
ContainerSpecificationCatalog.require(v).containerType()));
if (request.palletCount() != null) order.setPalletCount(request.palletCount());
if (request.packageCount() != null) order.setPackageCount(request.packageCount());
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark);
touch(order); orders.updateById(order);
order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim());
order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().trim());
order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); setPresent(request.remark(), order::setRemark);
touch(order); repository.updateOrder(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now());
return detail(customerId, orderNo);
}
/** 按版本幂等取消允许取消的订单。 */
@Transactional
public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ChangeStatus request) {
@@ -172,6 +150,7 @@ public class UsTruckOrderService {
order.getStatus(), CANCELLED, request.remark());
}
/** 审核订单,仅对真实已知预报建立待收托盘。 */
@Transactional
public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Review request) {
@@ -186,6 +165,7 @@ public class UsTruckOrderService {
PENDING_REVIEW, next, request.remark());
}
/** 记录实际收货,首次实收确定独立订单托数。 */
@Transactional
public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ReceivePallets request) {
@@ -198,9 +178,12 @@ public class UsTruckOrderService {
}
createPlannedPallets(order);
List<UsTruckOrderPallet> current = pallets.listOrder(customerId, order.getId());
int planned = value(order.getPalletCount());
List<UsTruckOrderPallet> current = repository.pallets(customerId, order.getId());
int actual = request.actualPalletCount();
// 独立下单没有预报托数,首次实收形成确认基准,后续超量仍需审核。
boolean firstReceipt = value(order.getPalletCount()) == 0;
int planned = firstReceipt ? actual : value(order.getPalletCount());
order.setPalletCount(planned);
List<Integer> packageCounts = request.palletPackageCounts() == null ? List.of()
: request.palletPackageCounts();
if (!packageCounts.isEmpty() && packageCounts.size() != actual) {
@@ -213,7 +196,7 @@ public class UsTruckOrderService {
LocalDateTime now = LocalDateTime.now();
for (int sequence = 1; sequence <= actual; sequence++) {
UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1)
: newPallet(order, sequence, "PDA_OVERAGE", now);
: newPallet(order, sequence, firstReceipt ? "PDA_RECEIPT" : "PDA_OVERAGE", now);
pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED");
if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1));
else if (actual == 1) pallet.setPackageCount(request.packageCount());
@@ -221,9 +204,9 @@ public class UsTruckOrderService {
pallet.setUpdatedAt(now);
if (pallet.getId() == null) {
pallet.setId(IdWorker.getId());
pallets.insert(pallet);
repository.insertPallet(pallet);
} else {
pallets.updateById(pallet);
repository.updatePallet(pallet);
}
}
order.setReceivedPalletCount(actual);
@@ -232,12 +215,13 @@ public class UsTruckOrderService {
: actual == planned ? WAITING_CONSOLIDATION : RECEIVING;
order.setStatus(next);
touch(order);
orders.updateById(order);
repository.updateOrder(order);
recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now);
history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now);
return detail(customerId, orderNo);
}
/** 审核后续实收超出已确认托数的部分。 */
@Transactional
public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ReviewPalletOverage request) {
@@ -250,14 +234,14 @@ public class UsTruckOrderService {
throw conflict("Only pallet-overage-review orders can be reviewed");
}
boolean approve = "APPROVE".equals(norm(request.decision()));
List<UsTruckOrderPallet> rows = pallets.listOrder(customerId, order.getId());
List<UsTruckOrderPallet> rows = repository.pallets(customerId, order.getId());
LocalDateTime now = LocalDateTime.now();
int accepted = value(order.getPalletCount());
for (UsTruckOrderPallet pallet : rows) {
if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue;
pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED");
pallet.setUpdatedAt(now);
pallets.updateById(pallet);
repository.updatePallet(pallet);
}
if (approve) {
accepted = value(order.getReceivedPalletCount());
@@ -267,13 +251,14 @@ public class UsTruckOrderService {
}
order.setStatus(WAITING_CONSOLIDATION);
touch(order);
orders.updateById(order);
repository.updateOrder(order);
recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now);
history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION,
operatorId, request.idempotencyKey(), request.remark(), now);
return detail(customerId, orderNo);
}
/** 分配已实收且尚未分配的订单托盘。 */
@Transactional
public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.AllocatePallets request) {
@@ -287,7 +272,7 @@ public class UsTruckOrderService {
}
Set<Long> requested = new HashSet<>(request.palletIds());
if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique");
List<UsTruckOrderPallet> rows = pallets.listOrder(customerId, order.getId());
List<UsTruckOrderPallet> rows = repository.pallets(customerId, order.getId());
List<UsTruckOrderPallet> selected = rows.stream().filter(row -> requested.contains(row.getId())).toList();
if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) {
throw conflict("Selected pallets must belong to the order and be received but unallocated");
@@ -296,7 +281,7 @@ public class UsTruckOrderService {
for (UsTruckOrderPallet pallet : selected) {
pallet.setStatus("ALLOCATED");
pallet.setUpdatedAt(now);
pallets.updateById(pallet);
repository.updatePallet(pallet);
}
int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count();
int accepted = value(order.getReceivedPalletCount());
@@ -306,13 +291,14 @@ public class UsTruckOrderService {
order.setAllocatedPalletCount(allocated);
order.setStatus(next);
touch(order);
orders.updateById(order);
repository.updateOrder(order);
recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now);
history(order, "ALLOCATE_PALLETS", from, next, operatorId,
request.idempotencyKey(), request.remark(), now);
return detail(customerId, orderNo);
}
/** 将全部分配完成的订单推进到待派车。 */
@Transactional
public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Command request) {
@@ -320,24 +306,28 @@ public class UsTruckOrderService {
PENDING_DISPATCH, request);
}
/** 确认订单已派车并记录操作历史。 */
@Transactional
public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Command request) {
return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request);
}
/** 确认订单进入运输阶段。 */
@Transactional
public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Command request) {
return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request);
}
/** 确认运输完成并保留幂等结果。 */
@Transactional
public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Command request) {
return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request);
}
/** 复核状态版本后执行单一履约动作。 */
private UsTruckOrderResponses.Detail command(String customerId, String orderNo, String operatorId,
String operationType, String expectedStatus, String nextStatus,
UsTruckOrderRequests.Command request) {
@@ -357,22 +347,24 @@ public class UsTruckOrderService {
expectedStatus, nextStatus, request.remark());
}
/** 原子更新状态并写入操作和历史。 */
private UsTruckOrderResponses.Detail transition(UsTruckOrder order, String operatorId,
String operationType, String idempotencyKey, Object request,
String from, String to, String remark) {
LocalDateTime now = LocalDateTime.now();
order.setStatus(to);
touch(order);
orders.updateById(order);
repository.updateOrder(order);
recordOperation(order, operationType, idempotencyKey, request, operatorId, now);
history(order, operationType, from, to, operatorId, idempotencyKey, remark, now);
return detail(order.getCustomerId(), order.getOrderNo());
}
/** 核对操作幂等键和请求摘要。 */
private UsTruckOrderResponses.Detail replayOperation(UsTruckOrder order, String operationType,
String idempotencyKey, Object request) {
String key = idempotencyKey.trim();
UsTruckOrderOperation existing = operations.findKey(order.getCustomerId(), order.getId(), key);
UsTruckOrderOperation existing = repository.findOperation(order.getCustomerId(), order.getId(), key);
if (existing == null) return null;
if (!operationType.equals(existing.getOperationType()) || !hash(request).equals(existing.getRequestHash())) {
throw conflict("Idempotency key was reused with a different truck operation payload");
@@ -380,6 +372,7 @@ public class UsTruckOrderService {
return detail(order.getCustomerId(), order.getOrderNo());
}
/** 记录不可变操作摘要和结果版本。 */
private void recordOperation(UsTruckOrder order, String operationType, String idempotencyKey,
Object request, String operatorId, LocalDateTime now) {
UsTruckOrderOperation operation = new UsTruckOrderOperation();
@@ -392,19 +385,23 @@ public class UsTruckOrderService {
operation.setResultVersion(order.getVersion());
operation.setCreatedBy(operatorId);
operation.setCreatedAt(now);
operations.insert(operation);
repository.insertOperation(operation);
}
/** 阻止旧版本覆盖当前订单。 */
private void requireVersion(UsTruckOrder order, Long requestedVersion) {
if (!requestedVersion.equals(value(order.getVersion()))) {
throw conflict("Order was changed; refresh and retry");
}
}
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; }
/** 按客户范围锁单并校验独立订单资格。 */
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; }
/** 复核创建请求摘要并返回既有订单。 */
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
/** 读取订单资料与实际托盘和操作记录。 */
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
List<UsTruckOrderResponses.Pallet> palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream()
List<UsTruckOrderResponses.Pallet> palletDetails = repository.pallets(o.getCustomerId(), o.getId()).stream()
.map(this::pallet)
.toList();
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(),
@@ -413,30 +410,37 @@ public class UsTruckOrderService {
h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(),
h.getRemark(), h.getCreatedAt())).toList());
}
/** 将实际托盘记录转换为响应。 */
private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) {
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
}
/** 构造客户及业务订单关联键。 */
private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(UsTruckOrder o) { return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(o.getCustomerId(), "US_TRUCK", o.getId()); }
/** 转换需求字段与承运安排摘要。 */
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return summary(o, fleetPool.arrangement(poolKey(o))); }
private UsTruckOrderResponses.Summary summary(UsTruckOrder o, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt(), arrangement); }
/** 转换需求字段与承运安排摘要。 */
private UsTruckOrderResponses.Summary summary(UsTruckOrder o, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getVehicleType(), o.getOrigin(), o.getDestination(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getOriginContactName(), o.getOriginContactPhone(), o.getDestinationContactName(), o.getDestinationContactPhone(), o.getPickupAt(), o.getDeliveryAt(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt(), arrangement); }
/** 保留订单状态变更和操作人证据。 */
private void history(UsTruckOrder o, String operationType, String from, String to, String operator,
String idempotencyKey, String remark, LocalDateTime at) {
UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory();
h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId());
h.setOperationType(operationType); h.setFromStatus(from); h.setToStatus(to);
h.setOperatorId(operator); h.setIdempotencyKey(trim(idempotencyKey)); h.setRemark(trim(remark));
h.setCreatedAt(at); histories.insert(h);
h.setCreatedAt(at); repository.insertHistory(h);
}
/** 仅为已知的预报数量建立待收托盘。 */
private void createPlannedPallets(UsTruckOrder order) {
int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size();
int existing = repository.pallets(order.getCustomerId(), order.getId()).size();
LocalDateTime now = LocalDateTime.now();
for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) {
UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now);
pallet.setId(IdWorker.getId());
pallets.insert(pallet);
repository.insertPallet(pallet);
}
}
/** 建立指定序号的订单托盘记录。 */
private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) {
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId());
@@ -445,17 +449,45 @@ public class UsTruckOrderService {
pallet.setCreatedAt(now); pallet.setUpdatedAt(now);
return pallet;
}
private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); }
private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); }
/** 校验状态、车型及取货时间筛选区间。 */
private void validateQuery(UsTruckOrderRequests.Query q) {
if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status");
if (StringUtils.hasText(q.vehicleType()) && !VEHICLE_TYPES.contains(q.vehicleType())) throw badRequest("请选择有效的车辆类型");
if (q.pickupFrom() != null && q.pickupTo() != null && q.pickupFrom().isAfter(q.pickupTo())) throw badRequest("取货时间起点不能晚于终点");
}
/** 校验两端必填资料和先取后送的时间顺序;不进行时区转换。 */
private void validateRequirements(String vehicleType, String originName, String originPhone,
String destinationName, String destinationPhone,
LocalDateTime pickupAt, LocalDateTime deliveryAt) {
if (vehicleType == null || !VEHICLE_TYPES.contains(vehicleType)) throw badRequest("请选择有效的车辆类型");
if (!StringUtils.hasText(originName) || !StringUtils.hasText(originPhone)
|| !StringUtils.hasText(destinationName) || !StringUtils.hasText(destinationPhone)) throw badRequest("请完整填写起运地和目的地联系人及联系电话");
if (pickupAt == null || deliveryAt == null) throw badRequest("请填写取货时间和送货时间");
if (deliveryAt.isBefore(pickupAt)) throw badRequest("送货时间不能早于取货时间");
}
/** 计算请求摘要以校验幂等重放。 */
private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } }
/** 推进订单版本和更新时间。 */
private static void touch(UsTruckOrder o) { o.setVersion(value(o.getVersion()) + 1); o.setUpdatedAt(LocalDateTime.now()); }
/** 仅更新非空业务文字。 */
private static void setText(String v, Consumer<String> c) { if (StringUtils.hasText(v)) c.accept(v.trim()); }
/** 更新请求明确携带的可选文字。 */
private static void setPresent(String v, Consumer<String> c) { if (v != null) c.accept(trim(v)); }
/** 统一处理可选文字空值。 */
private static String trim(String v) { return v == null || v.isBlank() ? null : v.trim(); }
/** 统一状态与币种编码大小写。 */
private static String norm(String v) { return v.trim().toUpperCase(); }
/** 未指定币种时使用美元。 */
private static String defaultCurrency(String v) { return StringUtils.hasText(v) ? norm(v) : "USD"; }
private static int value(Integer v) { return v == null ? 0 : v; } private static long value(Long v) { return v == null ? 0 : v; }
/** 将计数空值转换为零。 */
private static int value(Integer v) { return v == null ? 0 : v; }
/** 将计数空值转换为零。 */
private static long value(Long v) { return v == null ? 0 : v; }
/** 构造请求校验错误。 */
private static ResponseStatusException badRequest(String m) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, m); }
/** 构造状态或并发冲突错误。 */
private static ResponseStatusException conflict(String m) { return new ResponseStatusException(HttpStatus.CONFLICT, m); }
/** 构造客户范围内订单不存在错误。 */
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "US truck order not found"); }
}
@@ -2784,13 +2784,12 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
id BIGINT NOT NULL COMMENT '主键ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_no VARCHAR(64) NOT NULL COMMENT '订单号',
transport_type VARCHAR(32) NOT NULL COMMENT '运输类型',
vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空',
origin VARCHAR(255) NOT NULL COMMENT '起运地',
origin_address VARCHAR(500) NULL COMMENT '起运地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地',
destination_address VARCHAR(500) NULL COMMENT '目的地址',
container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报',
package_count INT NULL COMMENT '可选箱数,零或空值均有效',
received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数',
@@ -2798,9 +2797,12 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
budget_amount DECIMAL(18,2) NULL COMMENT '预算金额',
currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名',
contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式',
requested_date DATE NOT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人',
origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人',
destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区',
delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空',
status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态',
remark VARCHAR(1000) NULL COMMENT '备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
@@ -2813,7 +2815,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no),
UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key),
KEY idx_ll_us_truck_order_list(customer_id,status,created_at),
KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at)
KEY idx_ll_us_truck_order_date(customer_id,pickup_at,created_at)
) ENGINE=InnoDB COMMENT='美国卡车运输订单';
CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet (
@@ -2863,16 +2865,17 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号',
pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式',
requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -2881,7 +2884,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id),
UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key),
KEY idx_ll_local_looseboard_list(customer_id,status,created_at),
KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at)
KEY idx_ll_local_looseboard_date(customer_id,pickup_at,created_at)
) ENGINE=InnoDB COMMENT='本土散板订单';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
@@ -63,6 +63,7 @@ paths:
description: >-
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天;
该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。
仅出库派送用途(DELIVERY);上门取货入仓用途(PICKUP)不属于此日历排车来源。
最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。
一车一条,保留取消及完成状态;保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认。
已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。
@@ -1421,9 +1421,9 @@ paths:
- { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/TruckStatus' } }
- { name: transportType, in: query, schema: { type: string } }
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, in: query, schema: { type: string, format: date } }
- { name: vehicleType, in: query, schema: { type: string } }
- { name: pickupFrom, in: query, schema: { type: string, format: date-time } }
- { name: pickupTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } }
post:
summary: 客户创建美国本土卡订单
@@ -1455,7 +1455,6 @@ paths:
- { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } }
- { name: transportMode, in: query, schema: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } }
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, in: query, schema: { type: string, format: date } }
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
@@ -3610,11 +3609,10 @@ components:
id: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string }
orderNo: { type: string }
transportType: { type: string, nullable: true }
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空', nullable: true }
origin: { type: string }
destination: { type: string }
containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ }
palletCount: { type: integer }
palletCount: { type: integer, description: 已确认托数;新建独立订单为0,首次实收确定,不从车型推导 }
packageCount: { type: integer }
receivedPalletCount: { type: integer }
allocatedPalletCount: { type: integer }
@@ -3622,9 +3620,12 @@ components:
totalVolumeCbm: { type: number }
budgetAmount: { type: number, nullable: true }
currency: { type: string, nullable: true }
contactName: { type: string, nullable: true }
contactInfo: { type: string, nullable: true }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, nullable: true, maxLength: 128 }
originContactPhone: { type: string, nullable: true, maxLength: 128 }
destinationContactName: { type: string, nullable: true, maxLength: 128 }
destinationContactPhone: { type: string, nullable: true, maxLength: 128 }
pickupAt: { type: string, nullable: true, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
deliveryAt: { type: string, nullable: true, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
status: { $ref: '#/components/schemas/TruckStatus' }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
@@ -3662,25 +3663,28 @@ components:
LooseboardSummary:
type: object
description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。
required: [id, customerId, orderNo, transportMode, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
required: [id, customerId, orderNo, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
properties:
id: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string }
orderNo: { type: string }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES }
transportType: { type: string }
transportType: { type: string, nullable: true }
origin: { type: string }
destination: { type: string }
containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer }
totalWeightKg: { type: number }
totalVolumeCbm: { type: number }
budgetAmount: { type: number, nullable: true }
currency: { type: string, nullable: true }
contactName: { type: string, nullable: true }
contactInfo: { type: string, nullable: true }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, nullable: true }
originContactPhone: { type: string, nullable: true }
destinationContactName: { type: string, nullable: true }
destinationContactPhone: { type: string, nullable: true }
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 }
status: { $ref: '#/components/schemas/LooseboardStatus' }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
@@ -4031,45 +4035,47 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
TruckCreate:
type: object
required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey]
required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, idempotencyKey]
properties:
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 }
palletCount: { type: integer, minimum: 1 }
packageCount: { type: integer, nullable: true, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, maxLength: 128 }
contactInfo: { type: string, maxLength: 128 }
requestedDate: { type: string, format: date }
originContactName: { type: string, maxLength: 128 }
originContactPhone: { type: string, maxLength: 128 }
destinationContactName: { type: string, maxLength: 128 }
destinationContactPhone: { type: string, maxLength: 128 }
pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
TruckUpdate:
type: object
required: [version]
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt]
properties:
version: { type: integer, format: int64, minimum: 0 }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 }
palletCount: { type: integer, minimum: 1 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3 }
contactName: { type: string, maxLength: 128 }
contactInfo: { type: string, maxLength: 128 }
requestedDate: { type: string, format: date }
originContactName: { type: string, maxLength: 128 }
originContactPhone: { type: string, maxLength: 128 }
destinationContactName: { type: string, maxLength: 128 }
destinationContactPhone: { type: string, maxLength: 128 }
pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
LooseboardStatusChange:
@@ -4082,47 +4088,49 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardCreate:
type: object
required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
properties:
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
destinationContactName: { type: string, minLength: 1, maxLength: 128 }
destinationContactPhone: { type: string, minLength: 1, maxLength: 128 }
pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
LooseboardUpdate:
type: object
required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm]
required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, version, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm]
properties:
version: { type: integer, format: int64, minimum: 0 }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
destinationContactName: { type: string, minLength: 1, maxLength: 128 }
destinationContactPhone: { type: string, minLength: 1, maxLength: 128 }
pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 }
remark: { type: string, nullable: true, maxLength: 1000 }
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -134,6 +134,9 @@ class DeliveryTransferServiceTest {
@BeforeEach
void setUp() {
service = new DeliveryTransferService();
var outboundQuantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository();
ReflectionTestUtils.setField(outboundQuantities, "pallets", outboundPallets);
ReflectionTestUtils.setField(service, "outboundQuantities", outboundQuantities);
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
var actualSources = mock(DispatchCargoSourceQuery.class);
ReflectionTestUtils.setField(service, "cargoSources", actualSources);
@@ -731,6 +734,20 @@ class DeliveryTransferServiceTest {
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
}
/** 已有废弃出库即便快照误存五件,列表及详情汇总也应显示一箱。 */
@Test
void discardOutboundSummaryCountsReturnCartonOnceAcrossSkuLines() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301");
outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5);
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
var first = pallet(501L, "PALLET-1", null, null, "RT-1", 2);
first.setCartonMarks("BOX-1"); first.setReturnSkuQuantity(2L);
var second = pallet(502L, "PALLET-1", null, null, "RT-1", 3);
second.setCartonMarks("BOX-1"); second.setReturnSkuQuantity(3L);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(first, second));
assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount());
}
@Test
void dispatchOutboundStillRequiresItsDeliveryDocument() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 使用真实 Mapper SQL 验证已出库但箱唛状态残留时的废弃门禁。 */
class CargoDiscardAvailableStockMapperTest {
/** 全部出库不可选;部分托盘仍有库存时只返回该托盘,提交旧箱号同样失败。 */
@Test
void filtersShippedStockFromCandidatesCountsAndSelection() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),inbound_code VARCHAR(50),inbound_order_no VARCHAR(50),overseas_warehouse_id VARCHAR(20),overseas_warehouse_name VARCHAR(50),created_at TIMESTAMP,order_type VARCHAR(30))");
db.execute("CREATE TABLE ll_dropship_order_profile(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_position_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(20))");
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT,status VARCHAR(20),available_packages BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),available_packages BIGINT)");
db.execute("CREATE TABLE ll_dropship_carton(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,profile_id BIGINT,status VARCHAR(20),carton_sequence INT)");
db.execute("CREATE TABLE ll_dropship_carton_sku(customer_id VARCHAR(20),carton_id BIGINT,sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_dropship_sku_inventory(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,profile_id BIGINT,sku_code VARCHAR(20),product_name VARCHAR(50))");
db.update("INSERT INTO ll_order VALUES(1,'C','EXXHWK2809191130','IP-WH-20260917-979650','WH','测试仓',CURRENT_TIMESTAMP,'ONE_CLICK_DROPSHIP')");
db.update("INSERT INTO ll_dropship_order_profile VALUES(10,'C',1,100,1000,'ACTIVE'),(20,'C',1,200,2000,'ACTIVE')");
db.update("INSERT INTO ll_inventory_position VALUES(100,'C',10000,1000,'OCCUPIED',0),(200,'C',20000,2000,'OCCUPIED',0)");
db.update("INSERT INTO ll_inventory_balance VALUES(10000,'C',0),(20000,'C',0)");
// 复现库存已扣完,箱唛仍为 AVAILABLE 的状态,不能因此暴露可废弃库存。
db.update("INSERT INTO ll_dropship_carton VALUES(11,'C',1,10,'AVAILABLE',1),(21,'C',1,20,'AVAILABLE',1)");
db.update("INSERT INTO ll_dropship_carton_sku VALUES('C',11,'SKU-A'),('C',21,'SKU-A'),('C',21,'SKU-B')");
db.update("INSERT INTO ll_dropship_sku_inventory VALUES(1,'C',1,10,'SKU-A','货物A'),(2,'C',1,20,'SKU-A','货物A'),(3,'C',1,20,'SKU-B','货物B')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(CargoDiscardInventoryMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession(true)) {
var mapper = session.getMapper(CargoDiscardInventoryMapper.class);
assertTrue(mapper.candidates("C", null).isEmpty());
assertEquals(0, mapper.countAvailableOrder("C", 1L));
assertTrue(mapper.availableOrderCartons("C", 1L).isEmpty());
assertTrue(mapper.lockSelected("C", 1L, "SKU-A", List.of(11L)).isEmpty());
assertTrue(mapper.lockAvailable("C", 1L, "SKU-A", 20).isEmpty());
db.update("UPDATE ll_inventory_position SET available_packages=1 WHERE id=200");
db.update("UPDATE ll_inventory_balance SET available_packages=1 WHERE id=20000");
session.clearCache();
assertEquals(2, mapper.candidates("C", "EXXHWK").size());
assertEquals(1, mapper.countAvailableOrder("C", 1L));
assertEquals(1, mapper.countAvailable("C", 1L, "SKU-A"));
assertEquals(0, mapper.countAvailableProfile("C", 1L, 10L, "SKU-A"));
assertEquals(1, mapper.countAvailableProfile("C", 1L, 20L, "SKU-B"));
assertEquals(List.of(21L), mapper.availableOrderCartons("C", 1L).stream().map(c -> c.getId()).toList());
assertEquals(List.of(21L), mapper.lockSelected("C", 1L, "SKU-A", List.of(11L, 21L)).stream().map(c -> c.getId()).toList());
assertEquals(List.of(21L), mapper.lockAvailable("C", 1L, "SKU-A", 20).stream().map(c -> c.getId()).toList());
assertTrue(mapper.candidates("OTHER", null).isEmpty());
assertEquals(0, mapper.countAvailableOrder("OTHER", 1L));
// 仓位与余额必须属于相同客户,不能借用其他客户的正库存。
db.update("UPDATE ll_inventory_balance SET customer_id='OTHER' WHERE id=20000");
session.clearCache();
assertTrue(mapper.candidates("C", null).isEmpty());
}
}
}
@@ -189,6 +189,28 @@ class PdaCargoDiscardServiceTest {
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
}
/** 退货一箱五件按指定箱作业,不得按五箱或整托处理。 */
@Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() {
item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1");
when(returnCartons.selectById(31L)).thenReturn(carton);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertEquals("CARTON", task.scanMode());
assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks());
assertEquals(5, task.skus().get(0).plannedPackages());
assertEquals("件", task.skus().get(0).quantityUnit());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5)));
verifyNoInteractions(adjustments);
var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)),
List.of(101L), List.of("RETURN-BOX-1"), false);
service.confirm(identity, "CD-1", "PALLET-1", confirm);
assertEquals(1, application.getTotalPackages());
assertEquals(new BigDecimal("5.00"), application.getTotalFee());
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
@@ -104,4 +104,23 @@ class CargoDiscardOutboundServiceTest {
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
}
/** 同一退货箱含多个 SKU 时箱数只记一次,件数仍逐 SKU 保留。 */
@Test void returnCartonProjectionSeparatesOneBoxFromFivePiecesAndSyncsCompletion() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); first.setDiscardPackages(2);
second.setReturnCartonId(31L); second.setSourceType("RETURN_CARTON"); second.setDiscardPackages(3);
var carton = new ReturnInventoryCarton(); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("99");
carton.setCartonMark("BOX-31"); carton.setPalletCode("PALLET-1");
when(returnCartons.selectById(31L)).thenReturn(carton);
service.create(application, "OP");
assertEquals(1, document.getPackageCount());
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPackageCount).sum());
assertEquals(5, snapshots.stream().mapToLong(OutboundPalletItem::getReturnSkuQuantity).sum());
first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(3);
application.setStatus("PDA_COMPLETED");
service.sync(application);
assertEquals(1, document.getPackageCount());
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPickedPackageCount).sum());
}
}
@@ -99,6 +99,9 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository();
ReflectionTestUtils.setField(quantities, "items", items);
ReflectionTestUtils.setField(service, "quantities", quantities);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
@@ -227,6 +230,32 @@ class CargoDiscardServiceTest {
assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode());
}
/** 创建和已有单据查询均按退货箱去重,五件不可计为五箱。 */
@Test void returnMixedSkuCartonCreatesOneBoxAndExistingSummaryReadsOneBox() {
var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100");
carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("RT-1"); carton.setCartonMark("BOX-1");
carton.setStatus("AVAILABLE");
var skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("A"); skuA.setQuantity(2L);
var skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("B"); skuB.setQuantity(3L);
when(returnCartons.lockId(901L)).thenReturn(carton);
when(returnCartons.selectById(901L)).thenReturn(carton);
when(returnCartonItems.lockSku(901L, "A")).thenReturn(skuA);
when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1);
var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(null, 901L, "A", 2),
new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null));
assertEquals(1, storedApplication.get().getTotalPackages());
assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee());
assertEquals(1, detail.summary().totalPackages());
assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum());
storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00"));
var existing = service.detail("100", storedApplication.get().getDiscardNo());
assertEquals(1, existing.summary().totalPackages());
assertEquals(new BigDecimal("5.00"), existing.summary().totalFee());
}
@Test
void approvalPushesToPdaWithoutDeductingInventory() {
CargoDiscardApplication application = pendingApplication();
@@ -28,7 +28,7 @@ class FleetPoolOrderBridgeTest {
FleetPoolOrderBridge service;
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
@BeforeEach void setup(){
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
@@ -86,24 +86,25 @@ class FleetPoolOrderBridgeTest {
}
@Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){
var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op");
assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK");
assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getOriginContactName()).isEqualTo(source.contactName());
assertThat(looseOrder.getDestinationContactName()).isNull();
assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE");
assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel());
assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount());
assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm());
assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull();
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull();
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());
}
@Test void truckInquiryPreservesUnknownContainerWhileZeroActualCountsRemainRealUntilReceipt(){
@Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){
var source=input("US_TRUCK");service.confirm(source,"op");
assertThat(truckOrder.getContainerType()).isNull();assertThat(truckOrder.getPalletCount()).isEqualTo(source.expectedPalletCount());
assertThat(truckOrder.getVehicleType()).isNull();assertThat(truckOrder.getPalletCount()).isEqualTo(source.expectedPalletCount());
assertThat(truckOrder.getReceivedPalletCount()).isZero();assertThat(truckOrder.getAllocatedPalletCount()).isZero();
assertThat(truckOrder.getPackageCount()).isEqualTo(source.packageCount());assertThat(truckOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE");
assertThat(truckOrder.getPackageCount()).isEqualTo(source.packageCount());assertThat(truckOrder.getDestinationContactName()).isNull();assertThat(truckOrder.getDeliveryAt()).isNull();
assertThat(truckOrder.getOriginAddress()).isNull();assertThat(truckOrder.getDestinationAddress()).isNull();assertThat(truckOrder.getBudgetAmount()).isNull();
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");
assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);assertThat(truckOrder.getContainerType()).isNull();
assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);assertThat(truckOrder.getVehicleType()).isNull();
}
@Test void incomingMustMatchCustomerWarehouseAndDestinationAndNeverBindSecondTailOrder(){
service.confirm(input("LOCAL_LOOSEBOARD"),"op");
@@ -11,16 +11,21 @@ import java.util.*;
import static org.assertj.core.api.Assertions.*;
class FleetTripCalendarMapperTest {
/** 同时验证计划和实发的半开范围,取货车次不能混入送仓日历。 */
@Test void queriesAHalfOpenUtcWindowWithoutStatusFilteringAndCapsCandidateRows() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))");
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))");
var from=LocalDateTime.of(2026,11,1,0,0);var to=from.plusDays(1);
String insert="INSERT INTO ll_fleet_trip(trip_no,status,warehouse_id,planned_departure_at,expected_driver_end_at,stops_json) VALUES(?,?,1,?,?, '[]')";
jdbc.update(insert,"TR-BEFORE","PLANNED",from.minusSeconds(1),to);
jdbc.update(insert,"TR-FROM","CANCELLED",from,to);
jdbc.update(insert,"TR-LAST","COMPLETED",to.minusSeconds(1),to);
jdbc.update(insert,"TR-TO","IN_TRANSIT",to,to.plusDays(1));
jdbc.update(insert,"TR-PICKUP-PLAN","PLANNED",from,to);
jdbc.update(insert,"TR-PICKUP-ACTUAL","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET trip_purpose='PICKUP' WHERE trip_no LIKE 'TR-PICKUP-%'");
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no='TR-PICKUP-ACTUAL'",from.plusHours(1));
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.looseboard;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class LocalLooseboardPersistenceTest {
/** 使用模块基线和真实 Mapper 验证四项联系人、取送时间保存回读、更新及客户隔离。 */
@Test
void contactsAndLocalMinuteTimesRoundTripThroughActualMapper() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (");
String ddl = baseline.substring(start, baseline.indexOf(';', start));
// H2 使用真实字段定义,去掉 MySQL 专属的索引和表选项。
ddl = ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
ddl = ddl.replaceAll(",\\s*\\)$", ")");
new JdbcTemplate(ds).execute(ddl);
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(LocalLooseboardOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
var repository = new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository();
org.springframework.test.util.ReflectionTestUtils.setField(repository, "orders", mapper);
var order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1");
order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setOriginContactName("发货人"); order.setOriginContactPhone("+1 (555) 0100");
order.setDestinationContactName("收货人"); order.setDestinationContactPhone("+1-555-0101");
order.setPickupAt(LocalDateTime.of(2026, 10, 20, 9, 30)); order.setDeliveryAt(LocalDateTime.of(2026, 10, 20, 15, 45));
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
order.setCurrency("USD"); order.setStatus("PENDING_ACCEPTANCE"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64));
repository.insertOrder(order);
var saved = repository.findNo("C-1", order.getOrderNo());
assertEquals("发货人", saved.getOriginContactName()); assertEquals("+1 (555) 0100", saved.getOriginContactPhone());
assertEquals("收货人", saved.getDestinationContactName()); assertEquals("+1-555-0101", saved.getDestinationContactPhone());
assertEquals(order.getPickupAt(), saved.getPickupAt()); assertEquals(order.getDeliveryAt(), saved.getDeliveryAt());
assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip"));
saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15));
repository.updateOrder(saved);
var updated = repository.findKey("C-1", "roundtrip");
assertEquals("+1-555-0102", updated.getDestinationContactPhone());
assertEquals(LocalDateTime.of(2026, 10, 20, 16, 0), updated.getDeliveryAt());
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.looseboard;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class LocalLooseboardRequestsTest {
/** 验证真实 Bean Validation:新增和编辑均要求四项联系人及两个时间。 */
@Test void requiresContactsAndBothLocalTimes() throws Exception {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var json = new ObjectMapper().findAndRegisterModules();
ObjectNode valid = json.valueToTree(request());
assertTrue(validator.validate(request()).isEmpty());
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) {
ObjectNode missing = valid.deepCopy(); missing.remove(field);
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Create.class)).isEmpty(), field);
missing.remove("idempotencyKey"); missing.put("version", 0);
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Update.class)).isEmpty(), field);
}
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone")) {
ObjectNode blank = valid.deepCopy(); blank.put(field, " ");
assertFalse(validator.validate(json.treeToValue(blank, LocalLooseboardRequests.Create.class)).isEmpty(), field);
}
ObjectNode reversed = valid.deepCopy(); reversed.put("deliveryAt", "2030-01-02T10:29:00");
assertFalse(validator.validate(json.treeToValue(reversed, LocalLooseboardRequests.Create.class)).isEmpty());
}
}
/** JSON 往返保持当地分钟,不包含已删除字段。 */
@Test void roundTripsLocalTimesAndSplitContacts() throws Exception {
var json = new ObjectMapper().findAndRegisterModules();
var valid = request(); var tree = json.valueToTree(valid);
var restored = json.treeToValue(tree, LocalLooseboardRequests.Create.class);
assertEquals(valid, restored);
for (String field : List.of("transportMode", "containerType", "contactName", "contactInfo", "requestedDate", "scheduleValid")) assertFalse(tree.has(field));
}
/** 创建具有独立预计托数和件数的最小完整需求。 */
private LocalLooseboardRequests.Create request() {
return new LocalLooseboardRequests.Create("LA", null, "ONT", null, "货物", 3, 8,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "发货人", "1", "收货人", "2",
LocalDateTime.of(2030, 1, 2, 10, 30), LocalDateTime.of(2030, 1, 2, 15, 45), null, "create-1");
}
}
@@ -45,7 +45,7 @@ class LocalLooseboardSchemaContractTest {
}
@Test
void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception {
void demandFieldsAndUnknownPalletizingRemainSeparateInBothBaselines() throws Exception {
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
@@ -53,8 +53,13 @@ class LocalLooseboardSchemaContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : java.util.List.of(baseline, unified)) {
String block = tableBlock(sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE"), "ll_local_looseboard_order").toLowerCase();
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Unknown container must remain null when MyBatis omits its insert column");
assertTrue(block.contains("product_name varchar(255)"));
assertTrue(block.contains("estimated_pallet_count int"));
assertTrue(block.contains("pickup_at datetime(3)"));
assertTrue(block.contains("delivery_at datetime(3)"));
for (String field : java.util.List.of("origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone")) assertTrue(block.contains(field));
for (String field : java.util.List.of("transport_mode", "contact_name varchar", "contact_info", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.matches("(?s).*[,\\n]\\s*" + field + ".*"));
org.junit.jupiter.api.Assertions.assertFalse(block.contains("container_type"));
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*pallet_required\\s+varchar\\(8\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Forecast pallets must not invent YES or NO palletizing service");
}
@@ -40,18 +40,15 @@ class PlatformLocalLooseboardControllerTest {
@Test
void updateRequiresIndependentEditPermission() {
var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP",
"LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO,
null, "USD", null, null, null, null);
var v = create();
var request = new LocalLooseboardRequests.Update(0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request));
assertEquals(403, error.getStatusCode().value());
}
private LocalLooseboardRequests.Create create() {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null,
"Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD",
null, null, LocalDate.now(), null, "key-1");
return new LocalLooseboardRequests.Create("LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1");
}
private AuthenticatedUser user(Set<String> permissions) {
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.HashMap;
@@ -54,11 +55,13 @@ class LocalLooseboardServiceTest {
void setUp() {
service = new LocalLooseboardService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories);
var repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "orders", orders);
ReflectionTestUtils.setField(repository, "histories", histories);
ReflectionTestUtils.setField(repository, "operations", operations);
ReflectionTestUtils.setField(repository, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories);
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
@@ -86,31 +89,29 @@ class LocalLooseboardServiceTest {
assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals("SHARED_TRUCK", detail.summary().transportMode());
assertEquals("40HQ", detail.summary().containerType());
assertEquals("Alice", detail.summary().originContactName());
assertEquals("Bob", detail.summary().destinationContactName());
assertEquals("家居用品", detail.summary().productName());
assertEquals(3, detail.summary().estimatedPalletCount());
assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt());
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
"LOCAL_LOOSEBOARD".equals(value.getOrderType())
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001")
&& value.getInboundCode() == null
&& value.getPalletCount() == 0
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType())));
}
@Test
void createDefaultsTheContainerTypeAndRejectsRemovedTypes() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> {
saved.set(call.getArgument(0));
return 1;
});
when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", create("default-type", null));
assertEquals("40HQ", detail.summary().containerType());
assertThrows(BizException.class, () -> service.create("100", "7", create("retired-type", "40HC")));
void validatesRequiredTimesAndChronologyWithoutInventingActualPallets() {
var pickup = LocalDateTime.of(2030, 1, 2, 10, 30);
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, pickup.minusMinutes(1)));
org.junit.jupiter.api.Assertions.assertTrue(LocalLooseboardRequests.validSchedule(pickup, pickup.plusMinutes(1)));
}
@Test
@@ -193,27 +194,22 @@ class LocalLooseboardServiceTest {
}
private LocalLooseboardRequests.Create create(String key) {
return create(key, "40HQ");
}
private LocalLooseboardRequests.Create create(String key, String containerType) {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", containerType, 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
return new LocalLooseboardRequests.Create("Los Angeles", "Address A", "Ontario", "Address B",
"家居用品", 3, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", LocalDateTime.of(2030, 1, 2, 9, 30), LocalDateTime.of(2030, 1, 2, 10, 30), null, key);
}
private LocalLooseboardRequests.Update update(long version) {
return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null);
var v = create("unused");
return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(),
v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(),
v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
}
private LocalLooseboardOrder order(String status, long version) {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1");
order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES");
order.setPalletRequired("YES");
order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");
order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now());
@@ -107,7 +107,7 @@ class MerchantOverseasCustomerApiContractTest {
void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() {
AuthenticatedUser merchant = user(101L, "MERCHANT");
LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20,
null, null, null, null, null, null);
null, null, null, null, null);
InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, null, null, null, null, null);
@@ -60,7 +60,7 @@ class TaillegRealConsumerAcceptanceTest {
@EnableAutoConfiguration(excludeName = "com.baiyue.common.security.BaiyueSecurityAutoConfiguration")
@Import({LoadingCompletedConsumer.class, LastlegOrderNoGenerator.class, DropshipOrderService.class,
InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class,
WarehouseTransferService.class, LocalLooseboardService.class})
WarehouseTransferService.class, LocalLooseboardService.class, com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository.class})
@MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper",
"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper",
"com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper",
@@ -117,9 +117,9 @@ class TaillegRealConsumerAcceptanceTest {
if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) {
looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create(
"SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", "洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "40HQ", 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", REQUESTED_DATE,
"洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30),
MARKER, MARKER + "-LOOSEBOARD-KEY"));
}
@@ -24,23 +24,30 @@ class TransferInventorySourceTest {
assertFalse(request(null,null,null,null).isInboundModeValid());
assertTrue(request(null,null,"CUSTOMER_DELIVERY",LocalDateTime.now()).isInboundModeValid());
}
/** 已换标或正在换标的原箱不再展示;撤销、驳回及其他客户的记录不能误删当前箱。 */
@Test void originalBoxesIncludeBothSourcesButNeverAnotherCustomerWarehouseOrInbound() {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR,overseas_warehouse_id VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_carton(customer_id VARCHAR,order_id BIGINT,carton_mark VARCHAR,sku_code VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,operating_warehouse_id VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,operating_warehouse_id VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR,inbound_code VARCHAR,status VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR,transfer_order_id BIGINT,sku_code VARCHAR,product_name_zh VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_item_id BIGINT,carton_mark VARCHAR,counted_quantity BIGINT,sku_items_json VARCHAR,status VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_item_id BIGINT,carton_mark VARCHAR,customer_carton_mark VARCHAR,counted_quantity BIGINT,sku_items_json VARCHAR,status VARCHAR)");
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR,status VARCHAR,customer_confirmed_at TIMESTAMP)");
db.execute("CREATE TABLE ll_relabel_item(application_id BIGINT,customer_id VARCHAR,source_inbound_code VARCHAR,source_carton_mark VARCHAR,carton_mark VARCHAR)");
db.execute("CREATE TABLE ll_relabel_pda_unit_fact(item_id BIGINT,result VARCHAR)");
db.update("INSERT INTO ll_order VALUES(1,'A','IN','WH'),(2,'B','IN','WH'),(3,'A','IN','OTHER'),(4,'A','OTHER','WH')");
db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')");
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'A',1,'WH','ONT8','Amazon')");
db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('A',1,'RELABELED','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')");
db.update("INSERT INTO ll_relabel_application VALUES(51,'A','WH','COMPLETED',CURRENT_TIMESTAMP()),(52,'A','WH','PROCESSING',NULL),(53,'A','WH','CANCELLED',NULL),(54,'A','WH','REJECTED',NULL),(55,'B','WH','COMPLETED',CURRENT_TIMESTAMP()),(56,'A','OTHER','COMPLETED',CURRENT_TIMESTAMP())");
db.update("INSERT INTO ll_relabel_item VALUES(51,'A','IN','RELABELED','RELABELED'),(52,'A','IN',NULL,'CUSTOMER-OLD'),(53,'A','IN',NULL,'FIRST'),(54,'A','IN',NULL,'FIRST'),(55,'B','IN',NULL,'FIRST'),(56,'A','IN',NULL,'FIRST')");
db.update("INSERT INTO ll_relabel_pda_unit_fact VALUES(51,'RELABELLED')");
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'A',1,'WH','ONT8','Amazon','IN','DRAFT'),(20,'A',1,'WH','ONT8','Amazon','IN','PENDING_REVIEW')");
db.update("INSERT INTO ll_warehouse_transfer_item VALUES(11,'A',10,'SKU','Product')");
db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('A',10,11,'MEASURED',20,NULL,'COUNTED'),('A',10,11,'EXPECTED',NULL,NULL,'EXPECTED'),('B',10,11,'FOREIGN2',20,NULL,'COUNTED')");
db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('A',10,11,'MEASURED',NULL,20,NULL,'COUNTED'),('A',10,11,'RELABELED-TRANSFER','CUSTOMER-OLD',20,NULL,'COUNTED'),('A',10,11,'EXPECTED',NULL,NULL,NULL,'EXPECTED'),('A',20,11,'FIRST',NULL,20,NULL,'COUNTED'),('B',10,11,'FOREIGN2',NULL,20,NULL,'COUNTED')");
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN");
assertEquals(List.of("FIRST","MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
assertEquals(List.of("FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
assertEquals("ONT8",result.get(0).destinationWarehouseCode());
assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity());
}
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.transfer.repository.TransferReturnSourceRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -23,12 +24,15 @@ class TransferReturnSourceTest {
var orders = mock(ReturnOrderMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
var items = mock(ReturnInventoryCartonItemMapper.class);
var relabelItems = mock(RelabelItemMapper.class);
var repository = new TransferReturnSourceRepository();
ReflectionTestUtils.setField(repository, "orders", orders);
ReflectionTestUtils.setField(repository, "cartons", cartons);
ReflectionTestUtils.setField(repository, "items", items);
ReflectionTestUtils.setField(repository, "relabelItems", relabelItems);
var service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "returnSources", repository);
ReflectionTestUtils.setField(service, "inventorySources", mock(com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository.class));
var order = new ReturnOrder();
order.setOverseasWarehouseId("WH-1");
when(orders.findNo("C-1", "RET-1")).thenReturn(order);
@@ -38,6 +42,7 @@ class TransferReturnSourceTest {
var sku = new ReturnInventoryCartonItem();
sku.setCartonId(1L); sku.setSkuCode("SKU-1"); sku.setQuantity(3L);
when(items.listCartons(List.of(1L))).thenReturn(List.of(sku));
when(relabelItems.completedReturnCartonIds("C-1", List.of(1L))).thenReturn(List.of());
var result = service.sourceCartons("C-1", "WH-1", "RETURN_ORDER:RET-1");
@@ -45,6 +50,9 @@ class TransferReturnSourceTest {
assertEquals("BOX-1", result.get(0).cartonMark());
assertEquals("SKU-1", result.get(0).items().get(0).skuCode());
assertEquals(3L, result.get(0).items().get(0).quantity());
when(relabelItems.completedReturnCartonIds("C-1", List.of(1L))).thenReturn(List.of(1L));
assertTrue(service.sourceCartons("C-1", "WH-1", "RETURN_ORDER:RET-1").isEmpty(),
"释放占用后的已换标退货箱不再回到原单明细");
assertThrows(ResponseStatusException.class,
() -> service.sourceCartons("C-1", "OTHER-WH", "RETURN_ORDER:RET-1"));
verify(cartons, never()).currentReturnCartons("C-1", "OTHER-WH", "RET-1");
@@ -80,6 +80,7 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
@@ -247,6 +248,12 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources);
ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles);
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
var inventorySourceRepository = new com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository();
ReflectionTestUtils.setField(inventorySourceRepository, "snapshots", inboundSnapshots);
var reservations = mock(com.baiyue.lastleg.transfer.mapper.TransferSourceReservationMapper.class);
lenient().when(reservations.lockInbound(anyString(), any())).thenReturn(101L);
ReflectionTestUtils.setField(inventorySourceRepository, "reservations", reservations);
ReflectionTestUtils.setField(service, "inventorySources", inventorySourceRepository);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans);
ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines);
@@ -506,12 +513,19 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(transfers);
}
/** 来源箱快照先通过占用校验,再单独拒绝草稿中的外部换标箱。 */
@Test
void submittingOldDraftRechecksRelabelCartonOwnership() {
var transfer = transfer(WarehouseTransferService.DRAFT);
transfer.setOperatingWarehouseId("WH-1");
transfer.setInboundCode("IN-1");
transfer.setNeedRelabel(true);
transfer.setPackageCount(1);
var selected = new WarehouseTransferCarton();
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(selected));
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
var binding = new com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding();
binding.setCustomerCartonMark("FOREIGN-BOX");
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.trucking;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.mapper.*;
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class UsTruckOrderPersistenceTest {
/** 使用模块基线和真实 Mapper 验证新字段保存、回读、更新与客户隔离。 */
@Test
void contactsVehicleAndLocalMinuteTimesRoundTripThroughActualMapper() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_us_truck_order (");
String ddl = baseline.substring(start, baseline.indexOf(';', start));
// H2 使用真实字段定义,去掉 MySQL 专属的索引和表选项。
ddl = ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
ddl = ddl.replaceAll(",\\s*\\)$", ")");
new JdbcTemplate(ds).execute(ddl);
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(UsTruckOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(UsTruckOrderMapper.class);
var repository = new UsTruckOrderRepository(mapper, mock(UsTruckOrderPalletMapper.class), mock(UsTruckOrderStatusHistoryMapper.class), mock(UsTruckOrderOperationMapper.class));
var order = new UsTruckOrder();
order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1");
order.setVehicleType("PALLET_15"); order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setOriginContactName("发货人"); order.setOriginContactPhone("+1 (555) 0100");
order.setDestinationContactName("收货人"); order.setDestinationContactPhone("+1-555-0101");
order.setPickupAt(LocalDateTime.of(2026, 10, 20, 9, 30)); order.setDeliveryAt(LocalDateTime.of(2026, 10, 20, 15, 45));
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
order.setCurrency("USD"); order.setStatus("PENDING_REVIEW"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64));
repository.insertOrder(order);
var saved = repository.findNo("C-1", order.getOrderNo());
assertEquals("PALLET_15", saved.getVehicleType());
assertEquals("发货人", saved.getOriginContactName()); assertEquals("+1 (555) 0100", saved.getOriginContactPhone());
assertEquals("收货人", saved.getDestinationContactName()); assertEquals("+1-555-0101", saved.getDestinationContactPhone());
assertEquals(order.getPickupAt(), saved.getPickupAt()); assertEquals(order.getDeliveryAt(), saved.getDeliveryAt());
assertEquals(0, saved.getPalletCount());
assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip"));
saved.setVehicleType("PALLET_30"); saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15));
repository.updateOrder(saved);
var updated = repository.findKey("C-1", "roundtrip");
assertEquals("PALLET_30", updated.getVehicleType()); assertEquals("+1-555-0102", updated.getDestinationContactPhone());
assertEquals(LocalDateTime.of(2026, 10, 20, 16, 0), updated.getDeliveryAt());
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.trucking;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
class UsTruckOrderRequestsTest {
/** 新增的七项需求必须由服务端校验,不能仅依赖页面星号。 */
@Test
void bothEndpointsVehicleAndTimesAreMandatory() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required");
var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet());
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt"), fields);
}
}
/** 两种当前车型及国际电话原样受理,未知车型拒绝。 */
@Test
void onlyCurrentVehicleTypesAreAccepted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) {
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle");
assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty());
}
}
}
}
@@ -18,7 +18,7 @@ class UsTruckOrderSchemaContractTest {
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet"));
assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1"));
assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 0"));
assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0"));
assertTrue(sql.contains("allocated_pallet_count INT NOT NULL DEFAULT 0"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation"));
@@ -50,7 +50,7 @@ class UsTruckOrderSchemaContractTest {
}
@Test
void poolUnknownContainerSurvivesOmittedNullInsertColumnInBothBaselines() throws Exception {
void newTruckDemandFieldsExistAndRemovedInputsAreAbsentInBothBaselines() throws Exception {
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
@@ -59,8 +59,9 @@ class UsTruckOrderSchemaContractTest {
for (String sql : java.util.List.of(baseline, unified)) {
String normalized = sql.toLowerCase().replace("create table if not exists", "create table");
String block = tableBlock(normalized, "ll_us_truck_order");
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Unknown container must remain null when MyBatis omits its insert column");
for (String column : java.util.List.of("vehicle_type", "origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone", "pickup_at", "delivery_at")) assertTrue(block.contains(column));
for (String removed : java.util.List.of("transport_type", "container_type", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.contains(removed));
}
}
}
@@ -75,9 +75,9 @@ class UsTruckOrderControllerTest {
@Test
void platformCreateUsesExplicitCustomerAndCreatePermission() {
var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null,
"Ontario", null, "40HQ", 1, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO,
null, "USD", "Alice", "+1-555-0100", java.time.LocalDate.now(), null, "create-key");
var request = new UsTruckOrderRequests.Create("PALLET_15", "Los Angeles", null,
"Ontario", null, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO,
null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", java.time.LocalDateTime.now(), java.time.LocalDateTime.now().plusHours(1), null, "create-key");
platform.create(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_CREATE)),
"100", request);
verify(service).create("100", "7", request);
@@ -85,8 +85,8 @@ class UsTruckOrderControllerTest {
@Test
void platformUpdateUsesExplicitCustomerAndEditPermission() {
var request = new UsTruckOrderRequests.Update(0L, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null);
var request = new UsTruckOrderRequests.Update(0L, "PALLET_30", null, null, null, null,
null, null, null, null, null, "Alice", "100", "Bob", "200", java.time.LocalDateTime.now(), java.time.LocalDateTime.now().plusHours(1), null);
platform.update(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_EDIT)),
"100", "TK1", request);
verify(service).update("100", "TK1", "7", request);
@@ -54,18 +54,15 @@ class UsTruckOrderServiceTest {
void setUp() {
service = new UsTruckOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "repository", new com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository(orders, pallets, histories, operations));
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2);
lenient().when(orders.lockNo("100", "TK1")).thenAnswer(call -> order);
when(orders.findNo("100", "TK1")).thenAnswer(call -> order);
when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows);
lenient().when(orders.findNo("100", "TK1")).thenAnswer(call -> order);
lenient().when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows);
lenient().when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows);
when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> {
lenient().when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> {
historyRows.add(call.getArgument(0)); return 1;
});
lenient().when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
@@ -82,18 +79,39 @@ class UsTruckOrderServiceTest {
}
@Test
void createDefaultsTheContainerTypeAndRejectsRemovedTypes() {
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1");
when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows);
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> {
order = call.getArgument(0);
return 1;
});
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; });
var request = create("PALLET_15", "new-truck");
var detail = service.create("100", "operator", request);
assertEquals("PALLET_15", detail.summary().vehicleType());
assertEquals("Alice", detail.summary().originContactName());
assertEquals("Bob", detail.summary().destinationContactName());
assertEquals("+1-555-0101", detail.summary().destinationContactPhone());
assertEquals(request.pickupAt(), detail.summary().pickupAt());
assertEquals(request.deliveryAt(), detail.summary().deliveryAt());
assertEquals(0, detail.summary().palletCount());
assertTrue(detail.pallets().isEmpty());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid")));
}
var detail = service.create("100", "operator", create(null, "default-type"));
@Test
void firstActualReceiptAcceptsRealQuantityWithoutForecastOverage() {
order.setPalletCount(0);
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(0L, "APPROVE", "review-new", null));
assertTrue(palletRows.isEmpty());
var received = service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2), "first-real", null));
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, received.summary().status());
assertEquals(3, received.summary().receivedPalletCount());
assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus())));
}
assertEquals("40HQ", detail.summary().containerType());
assertThrows(BizException.class, () -> service.create("100", "operator", create("40HC", "retired-type")));
@Test
void reversedTimesAreRejectedBeforeCreatingAnOrder() {
var request = create("PALLET_30", "reversed");
var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid));
}
@Test
@@ -188,16 +206,16 @@ class UsTruckOrderServiceTest {
return new UsTruckOrderRequests.Command(version, key, key);
}
private UsTruckOrderRequests.Create create(String containerType, String key) {
return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, "Ontario", null,
containerType, 1, 0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice",
"+1-555-0100", LocalDate.now().plusDays(1), null, key);
private UsTruckOrderRequests.Create create(String vehicleType, String key) {
return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null,
0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key);
}
private UsTruckOrder order(String status, long version, int pallets) {
UsTruckOrder value = new UsTruckOrder();
value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1");
value.setTransportType("PORT_PICKUP"); value.setOrigin("Los Angeles"); value.setDestination("Ontario");
value.setVehicleType("PALLET_15"); value.setOrigin("Los Angeles"); value.setDestination("Ontario");
value.setPalletCount(pallets); value.setPackageCount(0); value.setReceivedPalletCount(0);
value.setAllocatedPalletCount(0); value.setStatus(status); value.setVersion(version);
value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt());
+2 -1
View File
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
@@ -61,6 +61,7 @@ const MODULE_DEPENDENCIES = new Map([
['pda-loading-link-regression', ['merchant-qualification-fixture']],
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
['dropship-mixed-palletization', ['merchant-qualification-fixture']],
['dropship-physical-carton-chain', ['merchant-qualification-fixture']],
['dropship-browser-fixture', ['merchant-qualification-fixture']],
['mixed-container-receiving', ['merchant-qualification-fixture']],
+2
View File
@@ -172,6 +172,8 @@ export async function run(context) {
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
`dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。
@@ -0,0 +1,134 @@
import { run as createOrder } from './orders.mjs'
import { run as measure } from './inbound-measurement.mjs'
import { run as createCustomer } from './merchant-qualification-fixture.mjs'
const MODULE = 'dropship-mixed-palletization'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。
export async function run(context) {
const originalMarker = context.marker
const savedContext = { ...context.context }
const originalManualCount = context.options.prepareDropshipManualCount
const records = []
const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false }
context.createdResources.dropshipMixedPalletization = result
function check(name, response, passed, details = '') {
context.assert(MODULE, name, response, passed, details, OPTIONS)
if (!passed) throw new Error(name)
}
async function command(name, method, path, body, headers = {}) {
const response = await context.request(method, path, body, undefined, headers)
const value = context.apiSuccess(MODULE, name, response, '', OPTIONS)
if (String(response?.body?.code) !== '200') throw new Error(`${name}: ${response?.body?.code}: ${response?.body?.message}`)
return value
}
async function rejected(name, method, path, body) {
const response = await context.request(method, path, body)
check(name, response, response.http === 200 && ['A0404', 'A0409'].includes(String(response.body?.code)), response.body?.message)
}
async function readTask(record) {
return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`)
}
try {
check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId))
for (const [index, count] of [2, 3, 1].entries()) {
const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index]
context.marker = `${originalMarker}-${label}`
if (index === 1) await createCustomer(context, { publishEnvironment: false })
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count],
totalPackageCount: count, palletCount: 1, skuCodes: [`MIX-SAME-${originalMarker}`.slice(-60), `MIX-SECOND-${originalMarker}`.slice(-60)] }
if (records.length) context.context.waitSplitRoute = { originWarehouseName: records[0].order.receivingWarehouseName,
channelCode: records[0].order.channelCode, destinationIndex: 0 }
context.options.prepareDropshipManualCount = true
const start = context.steps.length
await createOrder(context)
await measure(context)
const source = context.createdResources.dropshipManualCount
check(`${label}真实下单审核测量全部通过`, null, Boolean(source?.taskId)
&& !context.steps.slice(start).some((step) => !step.passed))
records.push({ label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source } })
check(`${label}实测物理箱数正确`, null, source.totalCartons === count)
}
check('两客户三票保持独立归属', null, records.length === 3 && new Set(records.map((r) => r.order.tenantId)).size === 2)
check('三票同仓同渠道同目的仓', null, records.every((r) => r.source.warehouseId === records[0].source.warehouseId
&& r.order.channelCode === records[0].order.channelCode && r.order.destinationWarehouseId === records[0].order.destinationWarehouseId))
context.marker = originalMarker
const warehouseId = records[0].source.warehouseId
const query = `warehouseId=${warehouseId}`
const generated = await command('读取第一票预生成托盘', 'GET', `${BASE}/inbound-orders/${records[0].source.inboundId}/pallets?accountId=${records[0].order.tenantId}`)
const palletNo = generated?.[0]?.palletNo
check('取得空的预生成DP托盘', null, Boolean(palletNo) && generated[0].status === 'OPEN' && generated[0].cartonCount === 0)
result.palletNo = palletNo; result.warehouseId = warehouseId
const palletPath = `${BASE}/pallets/resolve?${query}&palletNo=${encodeURIComponent(palletNo)}`
const initial = await command('先扫空托盘只读识别', 'GET', palletPath)
check('空托盘不会虚构客户组成', null, initial.pallet.cartonCount === 0 && initial.members.length === 0)
await rejected('无效托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?${query}&palletNo=DP-000000-0000`)
await rejected('跨仓托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?warehouseId=1&palletNo=${encodeURIComponent(palletNo)}`)
await rejected('无效箱码被拒绝', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=NO-SUCH-${originalMarker}`)
result.stage = 'COUNTING'
const counted = []
// 交替扫描客户 A/B,证明页面无需先手工切换入仓单。
const scans = records.flatMap((record) => Array.from({ length: record.source.totalCartons }, (_, index) => ({ record, boxCode: `${record.source.inboundCode}#${record.source.totalCartons}-${index + 1}` })))
scans.sort((a, b) => Number(a.boxCode.split('-').at(-1)) - Number(b.boxCode.split('-').at(-1)))
for (const [index, { record, boxCode }] of scans.entries()) {
const resolved = await command('跨客户只读扫码预览', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=${encodeURIComponent(boxCode)}`)
check('箱码返回原客户和原入仓任务', null, String(resolved.task.taskId) === record.source.taskId
&& String(resolved.task.tenantId) === record.order.tenantId && resolved.carton.skuLines.length === 2)
const before = await readTask(record)
check('读取箱码未改变清点和归托事实', null, before.countedCartons === counted.filter((x) => x.taskId === record.source.taskId).length)
if (before.taskStatus === 'PENDING') await command('确认时领取原任务', 'POST', `${BASE}/tasks/${record.source.taskId}/claim`, { warehouseId })
const body = { warehouseId, boxCode, palletNo: index === 0 ? null : palletNo,
skuLines: resolved.carton.skuLines.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode,
actualQuantity: line.expectedQuantity, discrepancyAction: 'MATCH' })), operationId: `${originalMarker}-count-${index}` }
const submitted = await command('按原任务确认逐箱实收', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
const replayed = await command('清点同键重放不重复入账', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
check('重放保持箱身份和清点数量', null, submitted.carton.cartonId === replayed.carton.cartonId && submitted.countedCartons === replayed.countedCartons)
counted.push({ taskId: record.source.taskId, boxCode, cartonId: submitted.carton.cartonId, inboundId: record.source.inboundId, skuLines: body.skuLines })
if (index === 0) await command('先扫箱再扫托盘批量归托', 'POST', `${BASE}/tasks/${record.source.taskId}/pallets/bind-pending`, { warehouseId, palletNo, operationId: `${originalMarker}-bind` })
}
const beforeSeal = await command('封托前逐客户汇总核对', 'GET', palletPath)
check('六个实物箱两客户三票汇总守恒', null, beforeSeal.pallet.cartonCount === 6 && beforeSeal.members.length === 3
&& new Set(beforeSeal.members.map((m) => String(m.customerId))).size === 2
&& beforeSeal.members.every((m) => m.cartonCount === records.find((r) => r.source.taskId === String(m.taskId))?.source.totalCartons))
const moved = counted.find((item) => item.taskId === records[1].source.taskId)
await command('按客户B原任务移回待绑托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-move-out` })
const movedTask = await readTask(records[1])
check('只改变原票归托数量', null, movedTask.unboundCartons === 1 && movedTask.countedCartons === 3)
await command('客户B已点箱归回当前混托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: palletNo, operationId: `${originalMarker}-move-back` })
const open = await command('归回后读取权威托盘', 'GET', palletPath)
const sealPath = `${BASE}/tasks/${records[0].source.taskId}/pallets/${open.pallet.palletId}/seal`
await command('混托整托封托', 'POST', sealPath, { warehouseId, expectedVersion: open.pallet.version, operationId: `${originalMarker}-seal` })
let sealed = await command('回查已封托状态', 'GET', palletPath)
check('托盘已封托且六箱完整', null, sealed.pallet.status === 'SEALED' && sealed.pallet.cartonCount === 6)
for (const record of records) {
record.task = await readTask(record)
const inbound = await command('封托后回查原票待分柜', 'GET', `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
check('参与票全部完成且进入待分柜', null, record.task.taskStatus === 'COMPLETED' && inbound.summary.processStatus === 'WAIT_SPLIT')
}
const actual = sealed.pallet.cartons
check('每箱客户归属和双SKU实收未合并', null, actual.length === counted.length && actual.every((carton) => {
const source = counted.find((item) => item.boxCode === carton.boxCode)
return source && String(carton.inboundId) === source.inboundId && carton.skuLines.length === 2
&& carton.skuLines.every((line) => source.skuLines.some((expected) => String(line.inboundDetailId) === String(expected.inboundDetailId) && line.actualQuantity === expected.actualQuantity))
}))
await rejected('已封托禁止移箱', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-sealed-move` })
await command('未入装柜计划允许解除封托', 'POST', `${BASE}/tasks/${records[0].source.taskId}/pallets/${sealed.pallet.palletId}/unseal`, { warehouseId, expectedVersion: sealed.pallet.version, operationId: `${originalMarker}-unseal` })
for (const record of records) check('解封同步恢复所有参与任务', null, (await readTask(record)).taskStatus === 'CLAIMED')
const reopened = await command('回查解封后开放状态', 'GET', palletPath)
await command('核对后重新封托', 'POST', sealPath, { warehouseId, expectedVersion: reopened.pallet.version, operationId: `${originalMarker}-reseal` })
sealed = await command('最终托盘事实', 'GET', palletPath)
result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted
result.stage = 'MIXED_PALLET_SEALED_VERIFIED'
for (const record of records) record.task = await readTask(record)
} finally {
context.marker = originalMarker; context.context = savedContext
context.options.prepareDropshipManualCount = originalManualCount
}
}
@@ -56,11 +56,13 @@ test('customer identity joins use the same explicit collation as existing fulfil
})
test('commercial schemas do not invent container or palletizing facts for pool orders', () => {
for (const name of ['ll_local_looseboard_order', 'll_us_truck_order']) {
for (const name of ['ll_local_looseboard_order']) {
const definition = table(overseas, 'baiyue_lastleg', name)
assert.match(definition, /container_type\s+varchar\(32\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i)
assert.doesNotMatch(definition, /container_type[^\n]*DEFAULT '40HQ'/i)
}
assert.doesNotMatch(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /container_type/i)
assert.match(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /vehicle_type/i)
assert.match(table(overseas, 'baiyue_lastleg', 'll_local_looseboard_order'), /pallet_required\s+varchar\(8\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i)
const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql')
assert.equal((sql.match(/MODIFY COLUMN/g) || []).length, 3)
@@ -270,11 +270,11 @@ async function procurementFlow(context, prerequisite) {
function truckPayload(context) {
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
return {
transportType: 'PORT_PICKUP', origin: `验收起运-${suffix}`, originAddress: 'Long Beach',
destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', containerType: '40HQ',
palletCount: 1, packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88,
currency: 'USD', contactName: '四期验收联系人', contactInfo: '13800000000',
requestedDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), remark: context.marker,
vehicleType: 'PALLET_15', origin: `验收起运-${suffix}`, originAddress: 'Long Beach',
destination: `验收目的-${suffix}`, destinationAddress: 'Ontario',
packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88,
currency: 'USD', originContactName: '验收发货人', originContactPhone: '+1-555-0100', destinationContactName: '验收收货人', destinationContactPhone: '+1-555-0101',
pickupAt: `${new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)}T09:30:00`, deliveryAt: `${new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)}T15:45:00`, remark: context.marker,
idempotencyKey: `${context.marker}-phase4-truck`.slice(0, 255),
}
}
@@ -282,11 +282,11 @@ function truckPayload(context) {
function looseboardPayload(context) {
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
return {
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
productName: '散板验收货物', estimatedPalletCount: 2,
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80,
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
destinationAddress: 'Ontario', packageCount: 3, totalWeightKg: 80,
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', originContactName: '散板验收联系人', originContactPhone: '13800000001',
destinationContactName: '散板验收收货人', destinationContactPhone: '13800000002', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:30:00',
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
}
}
@@ -367,7 +367,7 @@ async function truckFlow(context, customerId) {
const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path)
const before = summary(detail.data)
const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path,
{ version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` })
{ ...payload, idempotencyKey: undefined, version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` })
const after = summary(edited.data)
context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response,
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1