Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a338d924c6 | ||
|
|
e0677e7ae7 | ||
|
|
44c76d8953 | ||
|
|
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`
|
||||
@@ -9,6 +9,8 @@ import java.util.Arrays;
|
||||
enum BusinessFileCategory {
|
||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -67,7 +69,12 @@ enum BusinessFileCategory {
|
||||
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
|
||||
}
|
||||
|
||||
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
|
||||
void validateOwner(long tenantId) {
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
||||
}
|
||||
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 平台排车车辆与司机证据内部文件契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
||||
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
|
||||
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
||||
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
||||
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
||||
上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。
|
||||
Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。
|
||||
本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。
|
||||
统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
post:
|
||||
operationId: uploadDispatchEvidence
|
||||
summary: 上传本排车的车辆或司机证据
|
||||
parameters:
|
||||
- name: X-Operator-Id
|
||||
in: header
|
||||
required: true
|
||||
description: Lastleg 已认证操作者的正数 Long ID
|
||||
schema: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/MetadataOrError'}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
get:
|
||||
operationId: readDispatchEvidenceMetadata
|
||||
summary: 按本排车与证据类别联合校验并读取元数据
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/MetadataOrError'}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
get:
|
||||
operationId: downloadDispatchEvidence
|
||||
summary: 按完整复合归属读取文件内容
|
||||
responses:
|
||||
'200':
|
||||
description: >-
|
||||
成功返回真实文件;错误返回 application/json 统一业务错误。
|
||||
A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效,
|
||||
A0404 表示文件不存在、非有效文件或复合归属不匹配。
|
||||
headers:
|
||||
Cache-Control: {schema: {type: string, enum: [no-store]}}
|
||||
Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名}
|
||||
Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数}
|
||||
content:
|
||||
application/pdf: {schema: {type: string, format: binary}}
|
||||
image/jpeg: {schema: {type: string, format: binary}}
|
||||
image/png: {schema: {type: string, format: binary}}
|
||||
application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}}
|
||||
components:
|
||||
securitySchemes:
|
||||
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
description: 两类排车证据只属于平台,客户作用域一律拒绝
|
||||
schema: {type: string, enum: ['0']}
|
||||
BusinessType:
|
||||
name: businessType
|
||||
in: path
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/DispatchEvidenceType'}
|
||||
BusinessId:
|
||||
name: businessId
|
||||
in: path
|
||||
required: true
|
||||
description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入
|
||||
schema: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
FileId:
|
||||
name: fileId
|
||||
in: path
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
responses:
|
||||
MetadataOrError:
|
||||
description: >-
|
||||
统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件;
|
||||
A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配;
|
||||
S0503为存储不可用,S0500为元数据保存失败等内部异常。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/MetadataEnvelope'}
|
||||
- {$ref: '#/components/schemas/ErrorEnvelope'}
|
||||
schemas:
|
||||
PositiveLongString:
|
||||
type: string
|
||||
pattern: '^[1-9][0-9]*$'
|
||||
maxLength: 19
|
||||
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
||||
DispatchEvidenceType:
|
||||
type: string
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
|
||||
MetadataEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, enum: ['200']}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/Metadata'}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
Metadata:
|
||||
type: object
|
||||
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt]
|
||||
properties:
|
||||
fileId: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
ownerScope: {type: string, enum: [PLATFORM]}
|
||||
tenantId: {type: string, enum: ['0']}
|
||||
businessType: {$ref: '#/components/schemas/DispatchEvidenceType'}
|
||||
businessId: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]}
|
||||
sizeBytes:
|
||||
type: string
|
||||
pattern: '^[1-9][0-9]*$'
|
||||
description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串
|
||||
example: '1024'
|
||||
createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移}
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]}
|
||||
message: {type: string}
|
||||
data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.controller.InternalBusinessFileController;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DispatchEvidenceFileTest {
|
||||
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
||||
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
||||
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
||||
@TempDir Path root;
|
||||
|
||||
/** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */
|
||||
@BeforeAll static void metadata() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"),
|
||||
FileObjectEntity.class);
|
||||
}
|
||||
|
||||
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
var upload = pdf();
|
||||
for (long customer : new long[]{-1, 1, 900}) {
|
||||
assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) {
|
||||
assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception {
|
||||
var storage = storage();
|
||||
var category = BusinessFileCategory.require(type);
|
||||
for (var file : new MockMultipartFile[]{pdf(),
|
||||
new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")),
|
||||
new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) {
|
||||
var saved = storage.store(0, "71", file, category);
|
||||
assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes());
|
||||
assertThat(saved.contentType()).isEqualTo(file.getContentType());
|
||||
assertThat(saved.size()).isEqualTo(file.getSize());
|
||||
assertThat(saved.sha256()).matches("[0-9a-f]{64}");
|
||||
}
|
||||
for (var file : new MockMultipartFile[]{
|
||||
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]),
|
||||
new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)),
|
||||
new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")),
|
||||
new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")),
|
||||
new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()),
|
||||
new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()),
|
||||
new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) {
|
||||
assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception {
|
||||
var storage = storage();
|
||||
var category = BusinessFileCategory.require(type);
|
||||
byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES);
|
||||
var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content);
|
||||
var stored = storage.store(0, "71", boundary, category);
|
||||
assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES);
|
||||
var oversized = mock(MultipartFile.class);
|
||||
when(oversized.isEmpty()).thenReturn(false);
|
||||
when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1);
|
||||
assertThatThrownBy(() -> storage.store(0, "71", oversized, category))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
|
||||
verify(oversized, never()).getInputStream();
|
||||
verify(oversized, never()).transferTo(any(Path.class));
|
||||
}
|
||||
|
||||
/** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = storage();
|
||||
var service = service(files, storage);
|
||||
var ids = new AtomicLong(500);
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
|
||||
FileObjectEntity row = call.getArgument(0);
|
||||
row.setId(ids.incrementAndGet());
|
||||
return 1;
|
||||
});
|
||||
var first = service.upload(0, type, "71", 7, pdf());
|
||||
var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf",
|
||||
"%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII));
|
||||
var second = service.upload(0, type, "71", 7, secondFile);
|
||||
var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
|
||||
verify(files, times(2)).insert(rows.capture());
|
||||
assertThat(first.ownerScope()).isEqualTo("PLATFORM");
|
||||
assertThat(first.tenantId()).isZero();
|
||||
assertThat(first.businessType()).isEqualTo(type);
|
||||
assertThat(first.businessId()).isEqualTo("71");
|
||||
assertThat(first.fileId()).isEqualTo(501L);
|
||||
assertThat(second.fileId()).isEqualTo(502L);
|
||||
assertThat(first.fileName()).isEqualTo("evidence.pdf");
|
||||
assertThat(first.contentType()).isEqualTo("application/pdf");
|
||||
assertThat(first.sizeBytes()).isEqualTo(pdf().getSize());
|
||||
assertThat(first.sha256()).matches("[0-9a-f]{64}");
|
||||
assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt());
|
||||
assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey());
|
||||
assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes());
|
||||
assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes());
|
||||
verify(files, never()).updateById(any(FileObjectEntity.class));
|
||||
}
|
||||
|
||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
var row = new FileObjectEntity();
|
||||
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
|
||||
row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf");
|
||||
row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize());
|
||||
row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
|
||||
when(files.selectOne(any())).thenAnswer(call -> {
|
||||
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||||
assertThat(boundValue(query, "status")).isEqualTo("ACTIVE");
|
||||
assertThat(boundValue(query, "deleted")).isEqualTo(false);
|
||||
return Long.valueOf(0).equals(boundValue(query, "tenant_id"))
|
||||
&& type.equals(boundValue(query, "business_type"))
|
||||
&& "71".equals(boundValue(query, "business_id"))
|
||||
&& Long.valueOf(501).equals(boundValue(query, "id")) ? row : null;
|
||||
});
|
||||
when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes());
|
||||
var metadata = service.metadata(0, type, "71", 501);
|
||||
assertThat(metadata.fileId()).isEqualTo(501L);
|
||||
assertThat(metadata.ownerScope()).isEqualTo("PLATFORM");
|
||||
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes());
|
||||
verify(storage).read(row.getObjectKey());
|
||||
clearInvocations(storage);
|
||||
String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE;
|
||||
for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) {
|
||||
assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.download(0, candidate, "71", 501))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
}
|
||||
assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
/** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) {
|
||||
var service = mock(InternalBusinessFileService.class);
|
||||
var controller = new InternalBusinessFileController();
|
||||
ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token");
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
for (String token : new String[]{null, "", "wrong-token"}) {
|
||||
assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
/** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */
|
||||
private static Object boundValue(LambdaQueryWrapper<FileObjectEntity> query, String column) {
|
||||
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column
|
||||
+ " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}").matcher(query.getSqlSegment());
|
||||
assertThat(matcher.find()).as("复合归属条件 %s", column).isTrue();
|
||||
return query.getParamNameValuePairs().get(matcher.group(1));
|
||||
}
|
||||
|
||||
/** 创建隔离临时目录中的真实存储,不访问运行环境。 */
|
||||
private BusinessFileStorage storage() {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
return storage;
|
||||
}
|
||||
|
||||
/** 复用生产服务,数据库边界由 Mapper 替身记录实际查询。 */
|
||||
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||
var service = new InternalBusinessFileService();
|
||||
ReflectionTestUtils.setField(service, "files", files);
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
return service;
|
||||
}
|
||||
|
||||
/** 提供具有真实 PDF 文件头的最小上传内容。 */
|
||||
private static MockMultipartFile pdf() {
|
||||
return new MockMultipartFile("file", "evidence.pdf", "application/pdf",
|
||||
"%PDF-1.7\nevidence".getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
/** 使用图像编码器生成真实 JPG、PNG,避免测试只验证扩展名。 */
|
||||
private static byte[] image(String format) throws java.io.IOException {
|
||||
var output = new ByteArrayOutputStream();
|
||||
var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
assertThat(javax.imageio.ImageIO.write(image, format, output)).isTrue();
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
+22
@@ -35,6 +35,28 @@ public class PlatformDropshipPalletizationController {
|
||||
@Resource private DropshipPalletizationService service;
|
||||
@Resource private InboundCartonLabelPrintService printService;
|
||||
|
||||
/** 跨票识别箱码只读预览,清点仍按原任务领取并提交。 */
|
||||
@GetMapping("/cartons/resolve")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<DropshipPalletizationDtos.ScanContext> resolveWorkspaceCarton(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestParam @jakarta.validation.constraints.NotBlank @Size(max = 80) String boxCode) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.resolveWorkspaceCarton(warehouseId, boxCode, user.userId()));
|
||||
}
|
||||
|
||||
/** 获取当前实物托盘及客户组成,不要求托盘已与当前票绑定。 */
|
||||
@GetMapping("/pallets/resolve")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<DropshipPalletizationDtos.PalletWorkspace> workspacePallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestParam @jakarta.validation.constraints.Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.workspacePallet(warehouseId, palletNo));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/pending")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
|
||||
|
||||
@@ -18,6 +18,15 @@ public final class DropshipPalletizationDtos {
|
||||
|
||||
public record ClaimRequest(@Positive long warehouseId) {}
|
||||
|
||||
/** 跨票只读识别结果;确认清点前仍须领取对应任务。 */
|
||||
public record ScanContext(Task task, Carton carton) {}
|
||||
|
||||
/** 当前实物托盘及逐客户、逐票组成。 */
|
||||
public record PalletWorkspace(Pallet pallet, List<PalletMember> members) {}
|
||||
|
||||
public record PalletMember(Long taskId, Long inboundId, String inboundCode,
|
||||
Long customerId, String customerName, Integer cartonCount) {}
|
||||
|
||||
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
|
||||
|
||||
public record CountCartonRequest(@Positive long warehouseId,
|
||||
|
||||
@@ -15,6 +15,45 @@ import java.util.List;
|
||||
/** 一件代发逐箱清点、待绑托与封托事实。 */
|
||||
@Mapper
|
||||
public interface DropshipPalletizationMapper {
|
||||
/** 在指定仓库按稳定箱码识别任务,多个匹配由业务层拒绝。 */
|
||||
@Select("""
|
||||
SELECT DISTINCT t.id FROM fl_dropship_pallet_task t
|
||||
JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id AND o.deleted=0
|
||||
JOIN fl_inbound_carton_label l ON l.inbound_id=t.inbound_id AND l.tenant_id=t.tenant_id
|
||||
WHERE t.warehouse_id=#{warehouseId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'
|
||||
AND o.fulfillment_type='ONE_CLICK_DROPSHIP'
|
||||
""")
|
||||
List<Long> workspaceTaskIds(@Param("warehouseId") long warehouseId,@Param("boxCode") String boxCode);
|
||||
|
||||
/** 扫码预览为只读查询,不能把未知或已封托托盘默认成开放。 */
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE warehouse_id=#{warehouseId} AND pallet_no=#{palletNo}")
|
||||
PalletRow workspacePallet(@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo);
|
||||
|
||||
/** 按实际成员汇总客户与票,空的预生成托盘不虚构成员。 */
|
||||
@Select("""
|
||||
SELECT c.task_id,c.inbound_id,o.inbound_code,c.tenant_id customer_id,o.customer_name,COUNT(*) carton_count
|
||||
FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE c.pallet_id=#{palletId}
|
||||
GROUP BY c.task_id,c.inbound_id,o.inbound_code,c.tenant_id,o.customer_name
|
||||
ORDER BY o.inbound_code,c.task_id
|
||||
""")
|
||||
List<com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember> workspaceMembers(@Param("palletId") long palletId);
|
||||
|
||||
/** 混托成员必须与当前票同渠道、同目的仓,客户可以不同。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
JOIN fl_dropship_pallet_task t ON t.id=#{taskId}
|
||||
JOIN fl_inbound_order target ON target.id=t.inbound_id AND target.tenant_id=t.tenant_id
|
||||
WHERE c.pallet_id=#{palletId} AND (
|
||||
NOT (o.channel_code <=> target.channel_code)
|
||||
OR NOT (o.destination_type <=> target.destination_type)
|
||||
OR NOT (o.destination_warehouse_id <=> target.destination_warehouse_id)
|
||||
OR NOT (o.destination_code <=> target.destination_code))
|
||||
""")
|
||||
int workspaceIncompatibleMembers(@Param("taskId") long taskId,@Param("palletId") long palletId);
|
||||
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_dropship_pallet_task
|
||||
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember;
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 仓内跨票扫码与实物托盘组成查询,客户归属始终从箱唛事实取得。 */
|
||||
@Repository
|
||||
public class DropshipWorkspaceRepository {
|
||||
@Resource private DropshipPalletizationMapper mapper;
|
||||
|
||||
/** 按仓库和稳定箱码查找作业任务,不接受客户端指定客户归属。 */
|
||||
public List<Long> taskIdsForBox(long warehouseId, String boxCode) {
|
||||
return mapper.workspaceTaskIds(warehouseId, boxCode);
|
||||
}
|
||||
|
||||
/** 只读查询托盘,不因扫描托盘产生绑定或状态变化。 */
|
||||
public PalletRow pallet(long warehouseId, String palletNo) {
|
||||
return mapper.workspacePallet(warehouseId, palletNo);
|
||||
}
|
||||
|
||||
/** 保留每票客户与任务身份,供混托核对和按原任务移箱。 */
|
||||
public List<PalletMember> members(long palletId) {
|
||||
return mapper.workspaceMembers(palletId);
|
||||
}
|
||||
|
||||
/** 归托前锁定实物托盘,与封托互斥。 */
|
||||
public PalletRow lockPallet(String palletNo) {
|
||||
return mapper.palletByNo(palletNo);
|
||||
}
|
||||
|
||||
/** 按原始入仓快照检查运输兼容性,不接受客户端声明。 */
|
||||
public boolean compatible(long taskId, long palletId) {
|
||||
return mapper.workspaceIncompatibleMembers(taskId, palletId) == 0;
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -21,6 +21,34 @@ import java.util.*;
|
||||
public class DropshipPalletizationService {
|
||||
@Resource private DropshipPalletizationMapper mapper;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource private com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspaceRepository;
|
||||
|
||||
/** 只读识别其他客户的箱码;不自动领取、清点或绑定托盘。 */
|
||||
public ScanContext resolveWorkspaceCarton(long warehouseId,String boxCode,long operatorId){
|
||||
String code=normalize(boxCode);
|
||||
if(code==null) throw invalid("箱码不能为空");
|
||||
List<Long> ids=workspaceRepository.taskIdsForBox(warehouseId,code);
|
||||
if(ids.isEmpty()) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该一件代发箱码");
|
||||
if(ids.size()!=1) throw conflict("箱码匹配多个任务,请联系后台核对");
|
||||
Task target=task(ids.get(0),warehouseId,operatorId);
|
||||
Carton carton=resolveCarton(target.taskId(),warehouseId,code,operatorId);
|
||||
// 未领取任务仅允许预览,员工确认后由正式领取与清点接口再次检查权限和状态。
|
||||
if("PENDING".equals(target.taskStatus()) && Boolean.TRUE.equals(target.palletSetupReady())
|
||||
&& "请先领取任务".equals(carton.blockReason())){
|
||||
carton=new Carton(carton.cartonId(),carton.boxCode(),carton.inboundId(),carton.inboundCode(),
|
||||
carton.skuLines(),carton.countStatus(),carton.palletId(),carton.palletNo(),true,null);
|
||||
}
|
||||
return new ScanContext(target,carton);
|
||||
}
|
||||
|
||||
/** 按仓库返回真实托盘状态及全部成员,跨客户不改变每箱的原归属。 */
|
||||
public PalletWorkspace workspacePallet(long warehouseId,String palletNo){
|
||||
String code=normalize(palletNo);
|
||||
if(code==null) throw invalid("托盘码不能为空");
|
||||
PalletRow row=workspaceRepository.pallet(warehouseId,code);
|
||||
if(row==null) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该托盘码");
|
||||
return new PalletWorkspace(palletView(row,null),workspaceRepository.members(row.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
|
||||
@@ -195,11 +223,13 @@ public class DropshipPalletizationService {
|
||||
.map(p->palletView(p,task.getInboundCode())).toList();
|
||||
}
|
||||
|
||||
/** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */
|
||||
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
|
||||
PalletRow existing=mapper.palletByNo(palletNo);
|
||||
PalletRow existing=workspaceRepository.lockPallet(palletNo);
|
||||
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
|
||||
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
|
||||
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
|
||||
if(!workspaceRepository.compatible(task.getId(),existing.getId())) throw conflict("托内货物与当前票的渠道或目的仓不同,不能混托");
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,26 @@ info:
|
||||
version: 3.0.0
|
||||
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
|
||||
paths:
|
||||
/api/platform/v1/first-leg/pda-dropship/cartons/resolve:
|
||||
get:
|
||||
summary: 同仓跨客户识别箱码并预览原任务
|
||||
description: 只读,不领取或清点;返回 task 与 carton,确认时仍须领取原任务后按原 taskId 清点。拒绝其他作业员已领取任务及箱码歧义。
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {name: boxCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 80}}
|
||||
responses:
|
||||
'200': {description: 原任务和箱内 SKU 预览}
|
||||
/api/platform/v1/first-leg/pda-dropship/pallets/resolve:
|
||||
get:
|
||||
summary: 查询当前仓库真实托盘状态与逐客户逐票组成
|
||||
description: 扫描不产生归托;返回 pallet 与 members,成员保留 taskId、inboundId、inboundCode、customerId、customerName、cartonCount。不存在或跨仓的托盘拒绝,已封托返回真实状态。
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {name: palletNo, in: query, required: true, schema: {type: string, pattern: 'DP-[0-9]{6}-[0-9]{4}'}}
|
||||
responses:
|
||||
'200': {description: 实物托盘状态和成员汇总}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
|
||||
get:
|
||||
summary: 查询待领取或本人已领取任务
|
||||
|
||||
+50
-1
@@ -24,7 +24,56 @@ class DropshipPalletizationServiceTest {
|
||||
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
|
||||
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
|
||||
private final DropshipPalletizationService service=new DropshipPalletizationService();
|
||||
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
|
||||
private final com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspace=mock(com.baiyue.firstleg.repository.DropshipWorkspaceRepository.class);
|
||||
/** 使用正式仓储入口的等价锁定查询,保留既有扫码测试的事实准备。 */
|
||||
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); ReflectionTestUtils.setField(service,"workspaceRepository",workspace); when(workspace.lockPallet(anyString())).thenAnswer(call -> mapper.palletByNo(call.getArgument(0))); when(workspace.compatible(anyLong(),anyLong())).thenReturn(true); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
|
||||
|
||||
/** 跨客户预览不领取任务,不写入清点事实。 */
|
||||
@Test void workspaceResolvesPendingCustomerWithoutMutating(){
|
||||
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
|
||||
TaskRow other=task(); other.setTenantId(2L); other.setTaskStatus("PENDING"); other.setAssigneeId(null);
|
||||
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
|
||||
when(mapper.task(10L,100L)).thenReturn(other);
|
||||
when(mapper.label(10L,"BOX-1")).thenReturn(label());
|
||||
when(mapper.palletCountByInbound(2L,30L)).thenReturn(other.getExpectedPalletCount());
|
||||
var result=service.resolveWorkspaceCarton(100L," BOX-1 ",7L);
|
||||
assertThat(result.task().tenantId()).isEqualTo(2L);
|
||||
assertThat(result.carton().canCount()).isTrue();
|
||||
verify(mapper,never()).claim(anyLong(),anyLong(),anyLong(),anyString());
|
||||
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
|
||||
}
|
||||
|
||||
/** 不允许从跨票扫码接管其他作业员的任务。 */
|
||||
@Test void workspaceRejectsOtherOperatorsTaskAndAmbiguousBox(){
|
||||
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
|
||||
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
|
||||
TaskRow other=task(); other.setAssigneeId(9L); when(mapper.task(10L,100L)).thenReturn(other);
|
||||
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("其他人员");
|
||||
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L,11L));
|
||||
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("多个任务");
|
||||
}
|
||||
|
||||
/** 托盘预览必须返回已封托的真实状态,跨仓不存在时拒绝。 */
|
||||
@Test void workspacePalletNeverInventsOpenState(){
|
||||
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
|
||||
when(workspace.pallet(100L,"DP-260911-0001")).thenReturn(sealedPallet());
|
||||
when(workspace.members(20L)).thenReturn(java.util.List.of());
|
||||
when(mapper.cartons(20L)).thenReturn(java.util.List.of());
|
||||
assertThat(service.workspacePallet(100L,"DP-260911-0001").pallet().status()).isEqualTo("SEALED");
|
||||
assertThatThrownBy(()->service.workspacePallet(101L,"DP-260911-0001")).isInstanceOf(BizException.class).hasMessageContaining("未找到");
|
||||
}
|
||||
|
||||
/** 即使不同客户允许混托,运输渠道或目的仓不同仍须拒绝。 */
|
||||
@Test void incompatibleDestinationCannotBeMixed(){
|
||||
when(mapper.task(10L,100L)).thenReturn(task());
|
||||
when(mapper.palletCountByInbound(1L,30L)).thenReturn(2);
|
||||
when(mapper.label(10L,"BOX-1")).thenReturn(label());
|
||||
when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet());
|
||||
when(workspace.compatible(10L,20L)).thenReturn(false);
|
||||
assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),"DP-260911-0001","wrong-destination"),7L,"张三"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("渠道或目的仓不同");
|
||||
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
|
||||
}
|
||||
|
||||
@Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){
|
||||
when(mapper.inboundCode(1L,30L)).thenReturn("IN-1");
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.delivery.repository;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.HashSet;
|
||||
|
||||
/** 出库查询按实物箱汇总,退货混装 SKU 不重复计箱。 */
|
||||
@Repository
|
||||
public class OutboundQuantityRepository {
|
||||
@Resource private OutboundPalletItemMapper pallets;
|
||||
|
||||
/** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */
|
||||
public Integer boxes(OutboundOrder outbound) {
|
||||
if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount();
|
||||
var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId());
|
||||
if (lines.isEmpty()) return outbound.getPackageCount();
|
||||
var returnBoxes = new HashSet<String>();
|
||||
int count = 0;
|
||||
for (var line : lines) {
|
||||
if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
|
||||
returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks());
|
||||
} else {
|
||||
count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0));
|
||||
}
|
||||
}
|
||||
return Math.addExact(count, returnBoxes.size());
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -94,6 +94,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DeliveryTransferService {
|
||||
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
|
||||
@Resource private DispatchOrderMapper dispatchOrders;
|
||||
@Resource private DispatchItemMapper dispatchItems;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@@ -1631,7 +1632,8 @@ public class DeliveryTransferService {
|
||||
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
|
||||
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
|
||||
}
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
|
||||
/** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
|
||||
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
|
||||
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
|
||||
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
|
||||
|
||||
+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) " +
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.discard.model;
|
||||
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
|
||||
public final class CargoDiscardQuantities {
|
||||
/** 数量规则不需要实例化。 */
|
||||
private CargoDiscardQuantities() { }
|
||||
|
||||
/** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */
|
||||
public static int boxes(List<CargoDiscardItem> lines, boolean actual) {
|
||||
var returnBoxes = new HashSet<Long>();
|
||||
int total = 0;
|
||||
for (var line : lines) {
|
||||
int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages();
|
||||
if (line.getReturnCartonId() != null) {
|
||||
if (quantity > 0) returnBoxes.add(line.getReturnCartonId());
|
||||
} else {
|
||||
total = Math.addExact(total, quantity);
|
||||
}
|
||||
}
|
||||
return Math.addExact(total, returnBoxes.size());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -19,8 +19,9 @@ public final class PdaCargoDiscardDtos {
|
||||
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
|
||||
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
|
||||
}
|
||||
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
|
||||
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
|
||||
Integer actualPackages, String status) { }
|
||||
Integer actualPackages, String status, String quantityUnit) { }
|
||||
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
|
||||
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
|
||||
String scanMode, List<String> cartonMarks) { }
|
||||
|
||||
+8
-4
@@ -117,8 +117,8 @@ public class PdaCargoDiscardService {
|
||||
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
|
||||
if (items.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
|
||||
int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
.mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum();
|
||||
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
|
||||
items.listApplication(application.getCustomerId(), application.getId()), true);
|
||||
application.setTotalPackages(actualTotal);
|
||||
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
|
||||
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
|
||||
@@ -160,16 +160,20 @@ public class PdaCargoDiscardService {
|
||||
}
|
||||
|
||||
private static final String LOOSE = "散箱";
|
||||
/** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */
|
||||
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
|
||||
var allItems = items.listApplication(application.getCustomerId(), application.getId());
|
||||
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
|
||||
return grouped.entrySet().stream().map(entry -> {
|
||||
CargoDiscardItem first = entry.getValue().get(0);
|
||||
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
|
||||
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
|
||||
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
|
||||
isReturnItem(i) ? "件" : "箱")).toList();
|
||||
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
|
||||
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
|
||||
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
|
||||
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
|
||||
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
|
||||
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
|
||||
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
|
||||
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
|
||||
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.discard.repository;
|
||||
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
|
||||
import com.baiyue.lastleg.discard.model.CargoDiscardQuantities;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 从申请明细读取物理箱汇总,不修改历史库存或件数。 */
|
||||
@Repository
|
||||
public class CargoDiscardQuantityRepository {
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
|
||||
/** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */
|
||||
public int boxes(CargoDiscardApplication application) {
|
||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -39,7 +39,7 @@ public class CargoDiscardOutboundService {
|
||||
return outbound == null ? null : outbound.getOutboundNo();
|
||||
}
|
||||
|
||||
// The caller holds the application lock and owns the approval transaction.
|
||||
/** 审核事务持有申请锁时创建出库投影,汇总箱数与退货件数分开保存。 */
|
||||
public void create(CargoDiscardApplication application, String operatorId) {
|
||||
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
|
||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||
@@ -51,12 +51,13 @@ public class CargoDiscardOutboundService {
|
||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
|
||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
||||
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
|
||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
|
||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
|
||||
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
|
||||
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
|
||||
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
|
||||
var palletCodes = new java.util.HashSet<String>();
|
||||
var returnBoxIds = new java.util.HashSet<Long>();
|
||||
for (var line : lines) {
|
||||
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
|
||||
String palletNo;
|
||||
@@ -83,11 +84,12 @@ public class CargoDiscardOutboundService {
|
||||
}
|
||||
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
|
||||
OutboundPalletItem snapshot = new OutboundPalletItem();
|
||||
// Stable one-to-one projection key in a separate table, never used to deduct stock.
|
||||
// 出库明细与申请明细一一对应,仅作投影,不通过此表扣减库存。
|
||||
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
|
||||
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
|
||||
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
|
||||
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
|
||||
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages());
|
||||
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
|
||||
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
|
||||
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
|
||||
snapshots.add(snapshot);
|
||||
@@ -96,15 +98,21 @@ public class CargoDiscardOutboundService {
|
||||
snapshots.forEach(pallets::insert);
|
||||
}
|
||||
|
||||
/** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */
|
||||
public void sync(CargoDiscardApplication application) {
|
||||
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
|
||||
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
|
||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||
var returnBoxIds = new java.util.HashSet<Long>();
|
||||
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
|
||||
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
|
||||
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
|
||||
boolean returned = line.getReturnCartonId() != null;
|
||||
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
|
||||
snapshot.setPackageCount(boxes);
|
||||
if ("COMPLETED".equals(line.getPdaStatus())) {
|
||||
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
|
||||
snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
|
||||
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
|
||||
snapshot.setStatus("PICKED");
|
||||
}
|
||||
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
|
||||
@@ -117,7 +125,7 @@ public class CargoDiscardOutboundService {
|
||||
};
|
||||
outbound.setStatus(status);
|
||||
outbound.setLxCodes(null);
|
||||
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
|
||||
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
|
||||
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
|
||||
+9
-3
@@ -50,6 +50,7 @@ import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class CargoDiscardService {
|
||||
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
|
||||
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
|
||||
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
@@ -138,6 +139,7 @@ public class CargoDiscardService {
|
||||
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
|
||||
}
|
||||
|
||||
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
|
||||
@Transactional
|
||||
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
|
||||
CargoDiscardRequests.Create request) {
|
||||
@@ -198,7 +200,8 @@ public class CargoDiscardService {
|
||||
} else if (!warehouseId.equals(itemWarehouseId)) {
|
||||
throw conflict("One cargo discard application cannot span operating warehouses");
|
||||
}
|
||||
total = Math.addExact(total, input.discardPackages());
|
||||
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
|
||||
if (!returnSource) total = Math.addExact(total, input.discardPackages());
|
||||
}
|
||||
|
||||
for (Long cartonId : returnCartonCache.keySet()) {
|
||||
@@ -209,6 +212,7 @@ public class CargoDiscardService {
|
||||
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
|
||||
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
|
||||
}
|
||||
total = Math.addExact(total, returnCartonCache.size());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
@@ -506,10 +510,12 @@ public class CargoDiscardService {
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */
|
||||
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
|
||||
int boxes = quantities.boxes(value);
|
||||
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
|
||||
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
|
||||
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
|
||||
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
|
||||
}
|
||||
|
||||
|
||||
+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();
|
||||
}
|
||||
|
||||
/** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */
|
||||
|
||||
+57
-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,38 @@ public class WarehouseTransferService {
|
||||
List<Long> cartonIds = cartons.stream().map(ReturnInventoryCarton::getId).filter(Objects::nonNull).toList();
|
||||
Map<Long, List<ReturnInventoryCartonItem>> items = cartonIds.isEmpty() ? Map.of()
|
||||
: returnSources.cartonItems(cartonIds).stream().collect(Collectors.groupingBy(ReturnInventoryCartonItem::getCartonId));
|
||||
return cartons.stream().map(carton -> {
|
||||
return unreservedSourceCartons(customerId, warehouseId.trim(), source, cartons.stream().map(carton -> {
|
||||
List<ReturnInventoryCartonItem> rows = items.getOrDefault(carton.getId(), List.of());
|
||||
List<WarehouseTransferResponses.SourceCartonSku> contents = rows.stream()
|
||||
.filter(item -> item.getQuantity() != null && item.getQuantity() > 0)
|
||||
.map(item -> new WarehouseTransferResponses.SourceCartonSku(item.getSkuCode(), item.getQuantity())).toList();
|
||||
return new WarehouseTransferResponses.SourceCarton(carton.getCartonMark(), null, carton.getTotalQuantity(),
|
||||
carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), contents);
|
||||
}).toList();
|
||||
}).toList());
|
||||
}
|
||||
return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source).stream().map(row -> {
|
||||
var sourceRows = inventorySources != null
|
||||
? inventorySources.sourceCartons(customerId, warehouseId.trim(), source)
|
||||
: inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source);
|
||||
return unreservedSourceCartons(customerId, warehouseId.trim(), source, sourceRows.stream().map(row -> {
|
||||
var carton = new WarehouseTransferCarton(); carton.setSkuItemsJson(row.skuItemsJson());
|
||||
var contents = cartonContents(carton).stream().map(item -> new WarehouseTransferResponses.SourceCartonSku(item.skuCode(),item.quantity())).toList();
|
||||
if (contents.isEmpty()) contents = List.of(new WarehouseTransferResponses.SourceCartonSku(row.skuCode(),row.quantity()));
|
||||
return new WarehouseTransferResponses.SourceCarton(row.cartonMark(),row.productNameZh(),row.quantity(),
|
||||
row.destinationWarehouseCode(),row.destinationWarehouseName(),contents);
|
||||
}).toList();
|
||||
}).toList());
|
||||
}
|
||||
|
||||
/** 待审核即按原箱快照占用;所有来源查询复用此过滤,避免重复提交同一实物箱。 */
|
||||
private List<WarehouseTransferResponses.SourceCarton> unreservedSourceCartons(String customerId,
|
||||
String warehouseId, String source, List<WarehouseTransferResponses.SourceCarton> cartons) {
|
||||
if (inventorySources == null) return cartons;
|
||||
var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source);
|
||||
if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons;
|
||||
Set<String> occupied = occupiedMarks.stream()
|
||||
.filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT))
|
||||
.collect(Collectors.toSet());
|
||||
return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim()
|
||||
.toUpperCase(java.util.Locale.ROOT))).toList();
|
||||
}
|
||||
private static LocalDateTime arrivalTime(WarehouseTransferRequests.Create request) {
|
||||
return StringUtils.hasText(request.inboundCode()) ? null : request.estimatedArrivalAt();
|
||||
@@ -334,7 +351,7 @@ public class WarehouseTransferService {
|
||||
}
|
||||
|
||||
/** Platform-created transfers keep the existing create-and-submit behavior. */
|
||||
@Transactional
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public WarehouseTransferResponses.Detail createAndSubmit(String customerId, String operatorId,
|
||||
WarehouseTransferRequests.Create request) {
|
||||
WarehouseTransferResponses.Detail draft = create(customerId, operatorId, request);
|
||||
@@ -456,7 +473,7 @@ public class WarehouseTransferService {
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
/** 提交草稿前重新校验来源箱归属,防止草稿保存后库存已发生变化。 */
|
||||
public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) {
|
||||
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
|
||||
@@ -464,6 +481,12 @@ public class WarehouseTransferService {
|
||||
if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo);
|
||||
if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted");
|
||||
|
||||
if (!transfer.getTransferNo().equals(transfer.getInboundCode())) {
|
||||
// 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。
|
||||
if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null)
|
||||
throw conflict("库存来源已失效,请重新选择来源单");
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
|
||||
Set<String> allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode())
|
||||
? sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())
|
||||
@@ -477,6 +500,11 @@ public class WarehouseTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
// 来源箱归属校验通过后再写入占用快照,保证错误箱号返回明确提示。
|
||||
if (!transfer.getTransferNo().equals(transfer.getInboundCode()) && inventorySources != null) {
|
||||
reserveSourceCartons(customerId, transfer);
|
||||
}
|
||||
|
||||
LastlegOrder order = requireTailOrder(customerId, transfer);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
transfer.setStatus(PENDING_REVIEW);
|
||||
@@ -500,6 +528,29 @@ public class WarehouseTransferService {
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
|
||||
/** 提交时逐箱核对来源快照并记录占用,禁止并发提交时静默替换用户选择。 */
|
||||
private void reserveSourceCartons(String customerId, WarehouseTransferOrder transfer) {
|
||||
var available = new java.util.LinkedHashMap<String, String>();
|
||||
for (var box : sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())) {
|
||||
available.put(box.cartonMark().trim().toUpperCase(java.util.Locale.ROOT), box.cartonMark());
|
||||
}
|
||||
var selected = transferCartons.listOrder(customerId, transfer.getId());
|
||||
if (selected.size() != transfer.getPackageCount() || selected.isEmpty())
|
||||
throw conflict("转运箱数与来源箱明细不一致,请刷新后重新提交");
|
||||
var reserved = new java.util.LinkedHashMap<Long, String>();
|
||||
for (var carton : selected) {
|
||||
String mark = StringUtils.hasText(carton.getCustomerCartonMark())
|
||||
? carton.getCustomerCartonMark() : carton.getCartonMark();
|
||||
if (!StringUtils.hasText(mark)) throw conflict("转运箱缺少来源箱号,请刷新来源单");
|
||||
String canonical = available.remove(mark.trim().toUpperCase(java.util.Locale.ROOT));
|
||||
if (canonical == null) throw conflict("来源箱已被提交或不可用,请刷新来源单:" + mark);
|
||||
reserved.put(carton.getId(), canonical);
|
||||
}
|
||||
for (var carton : selected) {
|
||||
carton.setCustomerCartonMark(reserved.get(carton.getId()));
|
||||
transferCartons.updateById(carton);
|
||||
}
|
||||
}
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query) {
|
||||
return page(customerId, query, true);
|
||||
|
||||
+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 (
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+17
@@ -134,6 +134,9 @@ class DeliveryTransferServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DeliveryTransferService();
|
||||
var outboundQuantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository();
|
||||
ReflectionTestUtils.setField(outboundQuantities, "pallets", outboundPallets);
|
||||
ReflectionTestUtils.setField(service, "outboundQuantities", outboundQuantities);
|
||||
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
|
||||
var actualSources = mock(DispatchCargoSourceQuery.class);
|
||||
ReflectionTestUtils.setField(service, "cargoSources", actualSources);
|
||||
@@ -731,6 +734,20 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
|
||||
}
|
||||
|
||||
/** 已有废弃出库即便快照误存五件,列表及详情汇总也应显示一箱。 */
|
||||
@Test
|
||||
void discardOutboundSummaryCountsReturnCartonOnceAcrossSkuLines() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301");
|
||||
outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5);
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
var first = pallet(501L, "PALLET-1", null, null, "RT-1", 2);
|
||||
first.setCartonMarks("BOX-1"); first.setReturnSkuQuantity(2L);
|
||||
var second = pallet(502L, "PALLET-1", null, null, "RT-1", 3);
|
||||
second.setCartonMarks("BOX-1"); second.setReturnSkuQuantity(3L);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(first, second));
|
||||
assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchOutboundStillRequiresItsDeliveryDocument() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -189,6 +189,28 @@ class PdaCargoDiscardServiceTest {
|
||||
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
|
||||
}
|
||||
|
||||
/** 退货一箱五件按指定箱作业,不得按五箱或整托处理。 */
|
||||
@Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() {
|
||||
item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5);
|
||||
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
|
||||
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1");
|
||||
when(returnCartons.selectById(31L)).thenReturn(carton);
|
||||
when(applications.selectList(any())).thenReturn(List.of(application));
|
||||
var task = service.resolve(identity, "PALLET-1", "CD-1");
|
||||
assertEquals("CARTON", task.scanMode());
|
||||
assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks());
|
||||
assertEquals(5, task.skus().get(0).plannedPackages());
|
||||
assertEquals("件", task.skus().get(0).quantityUnit());
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5)));
|
||||
verifyNoInteractions(adjustments);
|
||||
var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)),
|
||||
List.of(101L), List.of("RETURN-BOX-1"), false);
|
||||
service.confirm(identity, "CD-1", "PALLET-1", confirm);
|
||||
assertEquals(1, application.getTotalPackages());
|
||||
assertEquals(new BigDecimal("5.00"), application.getTotalFee());
|
||||
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
|
||||
}
|
||||
|
||||
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
|
||||
looseTask(); item.setCartonScanRequired(true);
|
||||
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
|
||||
|
||||
+19
@@ -104,4 +104,23 @@ class CargoDiscardOutboundServiceTest {
|
||||
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
|
||||
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
|
||||
}
|
||||
|
||||
/** 同一退货箱含多个 SKU 时箱数只记一次,件数仍逐 SKU 保留。 */
|
||||
@Test void returnCartonProjectionSeparatesOneBoxFromFivePiecesAndSyncsCompletion() {
|
||||
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); first.setDiscardPackages(2);
|
||||
second.setReturnCartonId(31L); second.setSourceType("RETURN_CARTON"); second.setDiscardPackages(3);
|
||||
var carton = new ReturnInventoryCarton(); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("99");
|
||||
carton.setCartonMark("BOX-31"); carton.setPalletCode("PALLET-1");
|
||||
when(returnCartons.selectById(31L)).thenReturn(carton);
|
||||
service.create(application, "OP");
|
||||
assertEquals(1, document.getPackageCount());
|
||||
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPackageCount).sum());
|
||||
assertEquals(5, snapshots.stream().mapToLong(OutboundPalletItem::getReturnSkuQuantity).sum());
|
||||
first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
|
||||
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(3);
|
||||
application.setStatus("PDA_COMPLETED");
|
||||
service.sync(application);
|
||||
assertEquals(1, document.getPackageCount());
|
||||
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPickedPackageCount).sum());
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -99,6 +99,9 @@ class CargoDiscardServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new CargoDiscardService();
|
||||
var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository();
|
||||
ReflectionTestUtils.setField(quantities, "items", items);
|
||||
ReflectionTestUtils.setField(service, "quantities", quantities);
|
||||
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
|
||||
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
|
||||
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
|
||||
@@ -227,6 +230,32 @@ class CargoDiscardServiceTest {
|
||||
assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode());
|
||||
}
|
||||
|
||||
/** 创建和已有单据查询均按退货箱去重,五件不可计为五箱。 */
|
||||
@Test void returnMixedSkuCartonCreatesOneBoxAndExistingSummaryReadsOneBox() {
|
||||
var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100");
|
||||
carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("RT-1"); carton.setCartonMark("BOX-1");
|
||||
carton.setStatus("AVAILABLE");
|
||||
var skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("A"); skuA.setQuantity(2L);
|
||||
var skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("B"); skuB.setQuantity(3L);
|
||||
when(returnCartons.lockId(901L)).thenReturn(carton);
|
||||
when(returnCartons.selectById(901L)).thenReturn(carton);
|
||||
when(returnCartonItems.lockSku(901L, "A")).thenReturn(skuA);
|
||||
when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB);
|
||||
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
|
||||
when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1);
|
||||
var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of(
|
||||
new CargoDiscardRequests.Item(null, 901L, "A", 2),
|
||||
new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null));
|
||||
assertEquals(1, storedApplication.get().getTotalPackages());
|
||||
assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee());
|
||||
assertEquals(1, detail.summary().totalPackages());
|
||||
assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum());
|
||||
storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00"));
|
||||
var existing = service.detail("100", storedApplication.get().getDiscardNo());
|
||||
assertEquals(1, existing.summary().totalPackages());
|
||||
assertEquals(new BigDecimal("5.00"), existing.summary().totalFee());
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalPushesToPdaWithoutDeductingInventory() {
|
||||
CargoDiscardApplication application = pendingApplication();
|
||||
|
||||
+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());
|
||||
|
||||
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
const A0_LOCATION_PAGE_SIZE = 200
|
||||
const A0_MAX_LOCATION_MUTATIONS = 8
|
||||
@@ -61,6 +61,7 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['pda-loading-link-regression', ['merchant-qualification-fixture']],
|
||||
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
|
||||
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
|
||||
['dropship-mixed-palletization', ['merchant-qualification-fixture']],
|
||||
['dropship-physical-carton-chain', ['merchant-qualification-fixture']],
|
||||
['dropship-browser-fixture', ['merchant-qualification-fixture']],
|
||||
['mixed-container-receiving', ['merchant-qualification-fixture']],
|
||||
|
||||
@@ -172,6 +172,8 @@ export async function run(context) {
|
||||
|
||||
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
|
||||
|
||||
`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。
|
||||
|
||||
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
|
||||
|
||||
`dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { run as createOrder } from './orders.mjs'
|
||||
import { run as measure } from './inbound-measurement.mjs'
|
||||
import { run as createCustomer } from './merchant-qualification-fixture.mjs'
|
||||
|
||||
const MODULE = 'dropship-mixed-palletization'
|
||||
const BASE = '/api/platform/v1/first-leg/pda-dropship'
|
||||
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
|
||||
// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。
|
||||
export async function run(context) {
|
||||
const originalMarker = context.marker
|
||||
const savedContext = { ...context.context }
|
||||
const originalManualCount = context.options.prepareDropshipManualCount
|
||||
const records = []
|
||||
const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false }
|
||||
context.createdResources.dropshipMixedPalletization = result
|
||||
function check(name, response, passed, details = '') {
|
||||
context.assert(MODULE, name, response, passed, details, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
async function command(name, method, path, body, headers = {}) {
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
const value = context.apiSuccess(MODULE, name, response, '', OPTIONS)
|
||||
if (String(response?.body?.code) !== '200') throw new Error(`${name}: ${response?.body?.code}: ${response?.body?.message}`)
|
||||
return value
|
||||
}
|
||||
async function rejected(name, method, path, body) {
|
||||
const response = await context.request(method, path, body)
|
||||
check(name, response, response.http === 200 && ['A0404', 'A0409'].includes(String(response.body?.code)), response.body?.message)
|
||||
}
|
||||
async function readTask(record) {
|
||||
return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`)
|
||||
}
|
||||
try {
|
||||
check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId))
|
||||
for (const [index, count] of [2, 3, 1].entries()) {
|
||||
const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index]
|
||||
context.marker = `${originalMarker}-${label}`
|
||||
if (index === 1) await createCustomer(context, { publishEnvironment: false })
|
||||
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
|
||||
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
|
||||
context.context.orderDestinationType = 'OVERSEAS'
|
||||
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
|
||||
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
|
||||
context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count],
|
||||
totalPackageCount: count, palletCount: 1, skuCodes: [`MIX-SAME-${originalMarker}`.slice(-60), `MIX-SECOND-${originalMarker}`.slice(-60)] }
|
||||
if (records.length) context.context.waitSplitRoute = { originWarehouseName: records[0].order.receivingWarehouseName,
|
||||
channelCode: records[0].order.channelCode, destinationIndex: 0 }
|
||||
context.options.prepareDropshipManualCount = true
|
||||
const start = context.steps.length
|
||||
await createOrder(context)
|
||||
await measure(context)
|
||||
const source = context.createdResources.dropshipManualCount
|
||||
check(`${label}真实下单审核测量全部通过`, null, Boolean(source?.taskId)
|
||||
&& !context.steps.slice(start).some((step) => !step.passed))
|
||||
records.push({ label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source } })
|
||||
check(`${label}实测物理箱数正确`, null, source.totalCartons === count)
|
||||
}
|
||||
check('两客户三票保持独立归属', null, records.length === 3 && new Set(records.map((r) => r.order.tenantId)).size === 2)
|
||||
check('三票同仓同渠道同目的仓', null, records.every((r) => r.source.warehouseId === records[0].source.warehouseId
|
||||
&& r.order.channelCode === records[0].order.channelCode && r.order.destinationWarehouseId === records[0].order.destinationWarehouseId))
|
||||
context.marker = originalMarker
|
||||
const warehouseId = records[0].source.warehouseId
|
||||
const query = `warehouseId=${warehouseId}`
|
||||
const generated = await command('读取第一票预生成托盘', 'GET', `${BASE}/inbound-orders/${records[0].source.inboundId}/pallets?accountId=${records[0].order.tenantId}`)
|
||||
const palletNo = generated?.[0]?.palletNo
|
||||
check('取得空的预生成DP托盘', null, Boolean(palletNo) && generated[0].status === 'OPEN' && generated[0].cartonCount === 0)
|
||||
result.palletNo = palletNo; result.warehouseId = warehouseId
|
||||
const palletPath = `${BASE}/pallets/resolve?${query}&palletNo=${encodeURIComponent(palletNo)}`
|
||||
const initial = await command('先扫空托盘只读识别', 'GET', palletPath)
|
||||
check('空托盘不会虚构客户组成', null, initial.pallet.cartonCount === 0 && initial.members.length === 0)
|
||||
await rejected('无效托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?${query}&palletNo=DP-000000-0000`)
|
||||
await rejected('跨仓托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?warehouseId=1&palletNo=${encodeURIComponent(palletNo)}`)
|
||||
await rejected('无效箱码被拒绝', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=NO-SUCH-${originalMarker}`)
|
||||
result.stage = 'COUNTING'
|
||||
const counted = []
|
||||
// 交替扫描客户 A/B,证明页面无需先手工切换入仓单。
|
||||
const scans = records.flatMap((record) => Array.from({ length: record.source.totalCartons }, (_, index) => ({ record, boxCode: `${record.source.inboundCode}#${record.source.totalCartons}-${index + 1}` })))
|
||||
scans.sort((a, b) => Number(a.boxCode.split('-').at(-1)) - Number(b.boxCode.split('-').at(-1)))
|
||||
for (const [index, { record, boxCode }] of scans.entries()) {
|
||||
const resolved = await command('跨客户只读扫码预览', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=${encodeURIComponent(boxCode)}`)
|
||||
check('箱码返回原客户和原入仓任务', null, String(resolved.task.taskId) === record.source.taskId
|
||||
&& String(resolved.task.tenantId) === record.order.tenantId && resolved.carton.skuLines.length === 2)
|
||||
const before = await readTask(record)
|
||||
check('读取箱码未改变清点和归托事实', null, before.countedCartons === counted.filter((x) => x.taskId === record.source.taskId).length)
|
||||
if (before.taskStatus === 'PENDING') await command('确认时领取原任务', 'POST', `${BASE}/tasks/${record.source.taskId}/claim`, { warehouseId })
|
||||
const body = { warehouseId, boxCode, palletNo: index === 0 ? null : palletNo,
|
||||
skuLines: resolved.carton.skuLines.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode,
|
||||
actualQuantity: line.expectedQuantity, discrepancyAction: 'MATCH' })), operationId: `${originalMarker}-count-${index}` }
|
||||
const submitted = await command('按原任务确认逐箱实收', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
|
||||
const replayed = await command('清点同键重放不重复入账', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
|
||||
check('重放保持箱身份和清点数量', null, submitted.carton.cartonId === replayed.carton.cartonId && submitted.countedCartons === replayed.countedCartons)
|
||||
counted.push({ taskId: record.source.taskId, boxCode, cartonId: submitted.carton.cartonId, inboundId: record.source.inboundId, skuLines: body.skuLines })
|
||||
if (index === 0) await command('先扫箱再扫托盘批量归托', 'POST', `${BASE}/tasks/${record.source.taskId}/pallets/bind-pending`, { warehouseId, palletNo, operationId: `${originalMarker}-bind` })
|
||||
}
|
||||
const beforeSeal = await command('封托前逐客户汇总核对', 'GET', palletPath)
|
||||
check('六个实物箱两客户三票汇总守恒', null, beforeSeal.pallet.cartonCount === 6 && beforeSeal.members.length === 3
|
||||
&& new Set(beforeSeal.members.map((m) => String(m.customerId))).size === 2
|
||||
&& beforeSeal.members.every((m) => m.cartonCount === records.find((r) => r.source.taskId === String(m.taskId))?.source.totalCartons))
|
||||
const moved = counted.find((item) => item.taskId === records[1].source.taskId)
|
||||
await command('按客户B原任务移回待绑托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-move-out` })
|
||||
const movedTask = await readTask(records[1])
|
||||
check('只改变原票归托数量', null, movedTask.unboundCartons === 1 && movedTask.countedCartons === 3)
|
||||
await command('客户B已点箱归回当前混托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: palletNo, operationId: `${originalMarker}-move-back` })
|
||||
const open = await command('归回后读取权威托盘', 'GET', palletPath)
|
||||
const sealPath = `${BASE}/tasks/${records[0].source.taskId}/pallets/${open.pallet.palletId}/seal`
|
||||
await command('混托整托封托', 'POST', sealPath, { warehouseId, expectedVersion: open.pallet.version, operationId: `${originalMarker}-seal` })
|
||||
let sealed = await command('回查已封托状态', 'GET', palletPath)
|
||||
check('托盘已封托且六箱完整', null, sealed.pallet.status === 'SEALED' && sealed.pallet.cartonCount === 6)
|
||||
for (const record of records) {
|
||||
record.task = await readTask(record)
|
||||
const inbound = await command('封托后回查原票待分柜', 'GET', `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
|
||||
check('参与票全部完成且进入待分柜', null, record.task.taskStatus === 'COMPLETED' && inbound.summary.processStatus === 'WAIT_SPLIT')
|
||||
}
|
||||
const actual = sealed.pallet.cartons
|
||||
check('每箱客户归属和双SKU实收未合并', null, actual.length === counted.length && actual.every((carton) => {
|
||||
const source = counted.find((item) => item.boxCode === carton.boxCode)
|
||||
return source && String(carton.inboundId) === source.inboundId && carton.skuLines.length === 2
|
||||
&& carton.skuLines.every((line) => source.skuLines.some((expected) => String(line.inboundDetailId) === String(expected.inboundDetailId) && line.actualQuantity === expected.actualQuantity))
|
||||
}))
|
||||
await rejected('已封托禁止移箱', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-sealed-move` })
|
||||
await command('未入装柜计划允许解除封托', 'POST', `${BASE}/tasks/${records[0].source.taskId}/pallets/${sealed.pallet.palletId}/unseal`, { warehouseId, expectedVersion: sealed.pallet.version, operationId: `${originalMarker}-unseal` })
|
||||
for (const record of records) check('解封同步恢复所有参与任务', null, (await readTask(record)).taskStatus === 'CLAIMED')
|
||||
const reopened = await command('回查解封后开放状态', 'GET', palletPath)
|
||||
await command('核对后重新封托', 'POST', sealPath, { warehouseId, expectedVersion: reopened.pallet.version, operationId: `${originalMarker}-reseal` })
|
||||
sealed = await command('最终托盘事实', 'GET', palletPath)
|
||||
result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted
|
||||
result.stage = 'MIXED_PALLET_SEALED_VERIFIED'
|
||||
for (const record of records) record.task = await readTask(record)
|
||||
} finally {
|
||||
context.marker = originalMarker; context.context = savedContext
|
||||
context.options.prepareDropshipManualCount = originalManualCount
|
||||
}
|
||||
}
|
||||
@@ -56,11 +56,13 @@ test('customer identity joins use the same explicit collation as existing fulfil
|
||||
})
|
||||
|
||||
test('commercial schemas do not invent container or palletizing facts for pool orders', () => {
|
||||
for (const name of ['ll_local_looseboard_order', 'll_us_truck_order']) {
|
||||
for (const name of ['ll_local_looseboard_order']) {
|
||||
const definition = table(overseas, 'baiyue_lastleg', name)
|
||||
assert.match(definition, /container_type\s+varchar\(32\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i)
|
||||
assert.doesNotMatch(definition, /container_type[^\n]*DEFAULT '40HQ'/i)
|
||||
}
|
||||
assert.doesNotMatch(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /container_type/i)
|
||||
assert.match(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /vehicle_type/i)
|
||||
assert.match(table(overseas, 'baiyue_lastleg', 'll_local_looseboard_order'), /pallet_required\s+varchar\(8\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i)
|
||||
const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql')
|
||||
assert.equal((sql.match(/MODIFY COLUMN/g) || []).length, 3)
|
||||
|
||||
@@ -270,11 +270,11 @@ async function procurementFlow(context, prerequisite) {
|
||||
function truckPayload(context) {
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
|
||||
return {
|
||||
transportType: 'PORT_PICKUP', origin: `验收起运-${suffix}`, originAddress: 'Long Beach',
|
||||
destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', containerType: '40HQ',
|
||||
palletCount: 1, packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88,
|
||||
currency: 'USD', contactName: '四期验收联系人', contactInfo: '13800000000',
|
||||
requestedDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), remark: context.marker,
|
||||
vehicleType: 'PALLET_15', origin: `验收起运-${suffix}`, originAddress: 'Long Beach',
|
||||
destination: `验收目的-${suffix}`, destinationAddress: 'Ontario',
|
||||
packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88,
|
||||
currency: 'USD', originContactName: '验收发货人', originContactPhone: '+1-555-0100', destinationContactName: '验收收货人', destinationContactPhone: '+1-555-0101',
|
||||
pickupAt: `${new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)}T09:30:00`, deliveryAt: `${new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)}T15:45:00`, remark: context.marker,
|
||||
idempotencyKey: `${context.marker}-phase4-truck`.slice(0, 255),
|
||||
}
|
||||
}
|
||||
@@ -282,11 +282,11 @@ function truckPayload(context) {
|
||||
function looseboardPayload(context) {
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
|
||||
return {
|
||||
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
|
||||
productName: '散板验收货物', estimatedPalletCount: 2,
|
||||
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
|
||||
destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80,
|
||||
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
|
||||
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
|
||||
destinationAddress: 'Ontario', packageCount: 3, totalWeightKg: 80,
|
||||
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', originContactName: '散板验收联系人', originContactPhone: '13800000001',
|
||||
destinationContactName: '散板验收收货人', destinationContactPhone: '13800000002', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:30:00',
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,7 @@ async function truckFlow(context, customerId) {
|
||||
const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path)
|
||||
const before = summary(detail.data)
|
||||
const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path,
|
||||
{ version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` })
|
||||
{ ...payload, idempotencyKey: undefined, version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` })
|
||||
const after = summary(edited.data)
|
||||
context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response,
|
||||
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1
|
||||
|
||||
Reference in New Issue
Block a user