Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a563f63857 | ||
|
|
eb167347f6 | ||
|
|
c8608d50ba | ||
|
|
dff040b509 | ||
|
|
26a203b4eb | ||
|
|
21128b895f | ||
|
|
fafcf75014 |
@@ -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 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
|
||||
|
||||
@@ -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 |
@@ -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`
|
||||
@@ -4,9 +4,6 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Digits;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 费用项目请求模型。 */
|
||||
public final class FeeItemDtos {
|
||||
@@ -24,9 +21,26 @@ public final class FeeItemDtos {
|
||||
@NotNull Boolean warehousePayable,
|
||||
@NotNull Boolean warehouseReceivable,
|
||||
@NotNull Boolean allocationFee,
|
||||
@Size(max = 500) String description,
|
||||
@DecimalMin("0.00") @Digits(integer = 10, fraction = 2) BigDecimal defaultPrice
|
||||
@Size(max = 500) String description
|
||||
) {
|
||||
public SaveRequest(String category, String tradeScope, String feeCode,
|
||||
String feeTypeName, Boolean pickupPayable,
|
||||
Boolean pickupReceivable, Boolean warehousePayable,
|
||||
Boolean warehouseReceivable, Boolean allocationFee,
|
||||
String description) {
|
||||
this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable,
|
||||
pickupReceivable, warehousePayable, warehouseReceivable,
|
||||
allocationFee, description);
|
||||
}
|
||||
|
||||
/** Backward-compatible constructor for callers predating the stable fee code. */
|
||||
public SaveRequest(String category, String tradeScope, String feeTypeName,
|
||||
Boolean pickupPayable, Boolean pickupReceivable,
|
||||
Boolean warehousePayable, Boolean warehouseReceivable,
|
||||
Boolean allocationFee, String description) {
|
||||
this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable,
|
||||
warehousePayable, warehouseReceivable, allocationFee, description);
|
||||
}
|
||||
}
|
||||
|
||||
public record StatusRequest(
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 费用项目主数据。 */
|
||||
@Getter
|
||||
@@ -23,9 +20,6 @@ public class FeeItemEntity extends BaseEntity {
|
||||
private String feeCode;
|
||||
private String supplierCategory;
|
||||
private String feeTypeName;
|
||||
/** 业务选费时带入的默认价格,可清空且不限制实际成交金额。 */
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal defaultPrice;
|
||||
private Boolean pickupPayable;
|
||||
private Boolean pickupReceivable;
|
||||
private Boolean warehousePayable;
|
||||
|
||||
@@ -101,7 +101,6 @@ public class FeeItemService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存项目属性及可清空的默认价格,实际费用仍以业务录入金额为准。 */
|
||||
private static void copy(FeeItemEntity value, SaveRequest request) {
|
||||
boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|
||||
|| Boolean.TRUE.equals(request.warehousePayable());
|
||||
@@ -112,7 +111,6 @@ public class FeeItemService {
|
||||
value.setSupplierCategory(payable
|
||||
? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null);
|
||||
value.setFeeTypeName(request.feeTypeName().trim());
|
||||
value.setDefaultPrice(request.defaultPrice());
|
||||
value.setPickupPayable(request.pickupPayable());
|
||||
value.setPickupReceivable(request.pickupReceivable());
|
||||
value.setWarehousePayable(request.warehousePayable());
|
||||
|
||||
@@ -9,7 +9,6 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
|
||||
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
|
||||
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商',
|
||||
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
|
||||
default_price DECIMAL(12,2) NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改',
|
||||
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
|
||||
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
|
||||
warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与',
|
||||
|
||||
@@ -102,7 +102,6 @@ components:
|
||||
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
|
||||
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
|
||||
feeTypeName: {type: string, maxLength: 128}
|
||||
defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;为空不自动带价,业务录入时可修改,不追改已有费用}
|
||||
pickupPayable: {type: boolean}
|
||||
pickupReceivable: {type: boolean}
|
||||
warehousePayable: {type: boolean}
|
||||
@@ -119,7 +118,6 @@ components:
|
||||
feeCode: {type: string}
|
||||
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
|
||||
feeTypeName: {type: string}
|
||||
defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;仅用于业务选择费用项目时带入}
|
||||
pickupPayable: {type: boolean}
|
||||
pickupReceivable: {type: boolean}
|
||||
warehousePayable: {type: boolean}
|
||||
|
||||
@@ -96,10 +96,6 @@ class FeeManagementContractTest {
|
||||
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||
assertThat(schemas).containsKeys("FeeItemSaveRequest", "FeeItemResource", "FeeItemPage",
|
||||
"FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse");
|
||||
for (String schema : List.of("FeeItemSaveRequest", "FeeItemResource")) {
|
||||
assertThat(stringMap(properties(schemas, schema).get("defaultPrice")))
|
||||
.containsEntry("nullable", true).containsEntry("minimum", 0);
|
||||
}
|
||||
for (String response : List.of("FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse")) {
|
||||
assertThat(list(stringMap(schemas.get(response)).get("required"))).contains("timestamp");
|
||||
assertThat(properties(schemas, response)).containsKeys(
|
||||
|
||||
@@ -110,7 +110,6 @@ class FeeManagementServiceTest {
|
||||
|
||||
assertThat(created.getId()).isEqualTo(11L);
|
||||
assertThat(created.getAllocationFee()).isTrue();
|
||||
assertThat(created.getDefaultPrice()).isEqualByComparingTo("60.00");
|
||||
assertThat(created.getWarehouseReceivable()).isTrue();
|
||||
assertThat(created.getWarehousePayable()).isTrue();
|
||||
assertThat(created.getSupplierCategory()).isNull();
|
||||
@@ -123,7 +122,7 @@ class FeeManagementServiceTest {
|
||||
@DisplayName("自定义费用编码统一转为大写并检查客户内唯一性")
|
||||
void normalizesCustomFeeCode() {
|
||||
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
|
||||
"storage_fee", null, "仓库租赁费", false, false, true, true, true, "应付仓库租赁费", null);
|
||||
"storage_fee", "仓库租赁费", false, false, true, true, true, "应付仓库租赁费");
|
||||
|
||||
FeeItemEntity created = feeItems.create(9L, "admin", request);
|
||||
|
||||
@@ -135,7 +134,7 @@ class FeeManagementServiceTest {
|
||||
@DisplayName("并发创建相同费用编码返回业务冲突")
|
||||
void rejectsConcurrentDuplicateFeeCode() {
|
||||
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
|
||||
"STORAGE_FEE", null, "仓库租赁费", false, false, true, true, true, null, null);
|
||||
"STORAGE_FEE", "仓库租赁费", false, false, true, true, true, null);
|
||||
doThrow(new DuplicateKeyException("duplicate fee code")).when(repository).insertItem(any());
|
||||
|
||||
assertThatThrownBy(() -> feeItems.create(9L, "admin", request))
|
||||
@@ -157,38 +156,6 @@ class FeeManagementServiceTest {
|
||||
verify(repository, never()).itemCodeExists("STORAGE_FEE", 11L);
|
||||
}
|
||||
|
||||
/** 清空默认价只修改公共项目,不触碰已生成的费用。 */
|
||||
@Test
|
||||
void clearsDefaultPrice() {
|
||||
FeeItemEntity existing = item(true, true);
|
||||
existing.setDefaultPrice(new BigDecimal("60.00"));
|
||||
when(repository.findItem(11L)).thenReturn(existing);
|
||||
when(repository.updateItem(any())).thenReturn(1);
|
||||
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
|
||||
null, null, "仓库租赁费", false, false, true, true, true, null, null);
|
||||
|
||||
assertThat(feeItems.update(11L, 9L, "admin", request).getDefaultPrice()).isNull();
|
||||
verify(repository).updateItem(existing);
|
||||
}
|
||||
|
||||
/** 请求价格允许空值和零元,拒绝负数、过多小数和超出字段范围的金额。 */
|
||||
@Test
|
||||
void validatesDefaultPrice() {
|
||||
try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
for (String price : List.of("0", "60.25", "9999999999.99")) {
|
||||
var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null,
|
||||
"操作费", false, true, false, false, false, null, new BigDecimal(price));
|
||||
assertThat(validator.validate(request)).isEmpty();
|
||||
}
|
||||
for (String price : List.of("-1", "60.255", "10000000000")) {
|
||||
var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null,
|
||||
"操作费", false, true, false, false, false, null, new BigDecimal(price));
|
||||
assertThat(validator.validate(request)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("任一客户引用公共费用项目后都禁止删除")
|
||||
void rejectsDeletingItemBoundByAnyCustomer() {
|
||||
@@ -511,10 +478,9 @@ class FeeManagementServiceTest {
|
||||
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 构造带默认价格的公共费用项目。 */
|
||||
private static FeeItemDtos.SaveRequest itemRequest() {
|
||||
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", null, null, "仓库租赁费",
|
||||
false, false, true, true, true, "应付仓库租赁费", new BigDecimal("60.00"));
|
||||
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费",
|
||||
false, false, true, true, true, "应付仓库租赁费");
|
||||
}
|
||||
|
||||
private static InboundFeeDetailDtos.SaveRequest detailRequest(String direction) {
|
||||
|
||||
+5
@@ -35,6 +35,11 @@ public class AppointmentCargoService {
|
||||
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
|
||||
}
|
||||
|
||||
/** 查询下拉框与货物分页共用权威目录范围,不从当前页猜测目的仓。 */
|
||||
public List<String> destinations() {
|
||||
return scope().destinations().stream().distinct().sorted().toList();
|
||||
}
|
||||
|
||||
private record Scope(List<String> origins, List<String> destinations) { }
|
||||
private Scope scope() {
|
||||
final List<AppointmentWarehouseClient.Warehouse> catalog;
|
||||
|
||||
+8
@@ -53,6 +53,14 @@ public class AppointmentPlanningController {
|
||||
return ApiResponse.success(new Resolved(source,result.get(0)));
|
||||
}
|
||||
|
||||
/** 目的仓选项沿用货物查询权限,只返回同范围的美国FBA仓码。 */
|
||||
@GetMapping("/cargo/destinations")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<java.util.List<String>> destinations(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.destinations());
|
||||
}
|
||||
|
||||
@GetMapping("/cargo")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+23
-7
@@ -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)>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) " +
|
||||
|
||||
+58
-41
@@ -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);}
|
||||
}
|
||||
|
||||
+56
@@ -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);
|
||||
|
||||
+41
-20
@@ -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) { }
|
||||
|
||||
|
||||
+5
-5
@@ -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(
|
||||
|
||||
+14
-10
@@ -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;
|
||||
|
||||
+67
@@ -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); }
|
||||
}
|
||||
+82
-73
@@ -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, "本土散板订单不存在或无权查看");
|
||||
}
|
||||
|
||||
+10
@@ -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 " +
|
||||
|
||||
+22
@@ -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,
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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();
|
||||
}
|
||||
|
||||
/** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */
|
||||
|
||||
+51
-6
@@ -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,36 @@ 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;
|
||||
Set<String> occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).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 +349,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 +471,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 +479,13 @@ 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 (inventorySources != null) reserveSourceCartons(customerId, transfer);
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
|
||||
Set<String> allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode())
|
||||
? sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())
|
||||
@@ -500,6 +522,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);
|
||||
|
||||
+30
-32
@@ -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(
|
||||
|
||||
+6
-5
@@ -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;
|
||||
|
||||
+67
@@ -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); }
|
||||
}
|
||||
+106
-74
@@ -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 (
|
||||
|
||||
@@ -6,6 +6,26 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/appointment-planning/cargo/destinations:
|
||||
get:
|
||||
operationId: listAppointmentCargoDestinations
|
||||
summary: 读取预约货物查询的目的FBA仓选项
|
||||
description: >-
|
||||
仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
|
||||
与货物分页共用权威启用美国FBA目录,返回去重、按仓码排序的完整选项,不受货物当前分页或关键词限制。
|
||||
不增加仓库主档读取权限;目录失败返回503,不当作空选项。
|
||||
responses:
|
||||
'200':
|
||||
description: 业务响应;data为目的FBA仓码数组
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data: {type: array, items: {type: string}, example: [ABE4, ONT8]}
|
||||
'403': {description: 非平台或缺少任一读取权限}
|
||||
'503': {description: 权威目录不可用}
|
||||
/api/platform/v1/last-leg/appointment-planning/sources/resolve-order:
|
||||
get:
|
||||
operationId: resolveOrderAppointmentSource
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+15
@@ -8,6 +8,21 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentCargoServiceTest {
|
||||
/** 选项取完整权威美国FBA目录,不依赖当前页货物或新增仓库主档权限。 */
|
||||
@Test void destinationOptionsUseTheSameAmericanFbaScopeWithoutQueryingCargoRows() {
|
||||
var mapper = mock(AppointmentCargoMapper.class);
|
||||
var warehouses = mock(AppointmentWarehouseClient.class);
|
||||
when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of(
|
||||
new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"),
|
||||
new AppointmentWarehouseClient.Warehouse(2L,"ONT8","ONT8","FBA","US"),
|
||||
new AppointmentWarehouseClient.Warehouse(3L,"ABE4","ABE4","FBA","US"),
|
||||
new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA"),
|
||||
new AppointmentWarehouseClient.Warehouse(5L,"ONT8","ONT8","FBA","US"))));
|
||||
assertEquals(List.of("ABE4","ONT8"),new AppointmentCargoService(mapper,warehouses,"token").destinations());
|
||||
verifyNoInteractions(mapper);
|
||||
when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline"));
|
||||
assertEquals(503,assertThrows(ResponseStatusException.class,()->new AppointmentCargoService(mapper,warehouses,"token").destinations()).getStatusCode().value());
|
||||
}
|
||||
@Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() {
|
||||
var mapper = mock(AppointmentCargoMapper.class);
|
||||
var warehouses = mock(AppointmentWarehouseClient.class);
|
||||
|
||||
+14
@@ -17,6 +17,20 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentPlanningAuthorizationTest {
|
||||
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
|
||||
/** 目的仓选项不要求仓库管理权限,但保留原两项读取权限和平台身份边界。 */
|
||||
@Test void destinationOptionsRequireTheExistingCargoReadPermissionsAndPlatformIdentity() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentPlanningController.class);
|
||||
var service=context.getBean(AppointmentCargoService.class);
|
||||
login(LIST);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM")));
|
||||
login(CARGO);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM")));
|
||||
login(LIST,CARGO);
|
||||
assertThrows(ResponseStatusException.class,()->controller.destinations(user(1L,"CLIENT")));
|
||||
verifyNoInteractions(service);
|
||||
when(service.destinations()).thenReturn(List.of("ABE4","ONT8"));
|
||||
assertEquals(List.of("ABE4","ONT8"),controller.destinations(user(0L,"PLATFORM")).data());
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test void orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentPlanningController.class);
|
||||
|
||||
+70
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -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");
|
||||
|
||||
+6
-1
@@ -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);
|
||||
|
||||
+62
@@ -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());
|
||||
}
|
||||
}
|
||||
+51
@@ -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");
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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");
|
||||
}
|
||||
|
||||
+3
-6
@@ -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) {
|
||||
|
||||
+29
-33
@@ -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());
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+4
-4
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
+13
-6
@@ -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());
|
||||
}
|
||||
|
||||
+8
@@ -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");
|
||||
|
||||
+14
@@ -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");
|
||||
|
||||
+64
@@ -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());
|
||||
}
|
||||
}
|
||||
+35
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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));
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
+38
-20
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user