Compare commits

..
33 Commits
Author SHA1 Message Date
ouyangxq 6e451a82da 支持私人地址人工头程报价以闭合预装费用前置 2026-11-04 12:10:14 +08:00
ouyangxq 78ecd2e039 同步派送异常退回订单类型的测试断言 2026-11-04 11:26:13 +08:00
liuyihe 3b1e38cf90 Merge remote-tracking branch 'origin/test' into test 2026-11-04 11:13:44 +08:00
liuyihe ba49035942 提交 2026-11-04 11:13:29 +08:00
ouyangxq 91e4fc33ff 补齐Lastleg回归夹具与现行接口合同断言 2026-11-04 11:12:01 +08:00
liuyihe 413e1e83e8 提交 2026-11-04 10:39:00 +08:00
ouyangxq c3a6519ce4 修正Lastleg废弃箱唛字段的初始化归属 2026-11-04 10:24:39 +08:00
ouyangxq 2eb0b65ba5 修复海外散货托盘收货与短少入库闭环 2026-11-04 09:57:14 +08:00
liuyihe b95281bacb 提交 2026-11-03 16:56:14 +08:00
liuyihe 5d6b2812cf 提交 2026-11-03 16:51:30 +08:00
liuyihe 1506576c67 提交 2026-11-03 16:14:51 +08:00
liuyihe 7b5ef9ba64 提交 2026-11-03 11:41:58 +08:00
liuyihe 21a9ce7d27 提交 2026-11-02 17:14:02 +08:00
ouyangxq 035e7e06af 修复费用审核前调整和运输取消及对账明细约束 2026-10-30 18:51:28 +08:00
liuyihe 5825739b92 提交 2026-10-30 18:46:44 +08:00
liuyihe edd4b1cd38 提交 2026-10-30 18:46:18 +08:00
liuyihe 51a93a69af 提交 2026-10-30 17:59:12 +08:00
ouyangxq 872215367b 修复废弃预留重放与预算到期时区 2026-10-30 15:42:39 +08:00
liuyihe ac013c265b 提交 2026-10-30 13:48:20 +08:00
liuyihe e1bfcfc8f5 提交 2026-10-30 11:54:49 +08:00
liuyihe 0bd25de906 提交 2026-10-29 15:49:18 +08:00
liuyihe 849f8dc5f2 提交 2026-10-29 14:29:02 +08:00
liuyihe 1b27d7e6c2 提交 2026-10-29 13:36:38 +08:00
ouyangxq 353c86f4cf 为船期节点记录并返回操作人快照 2026-10-28 16:56:33 +08:00
liuyihe 71a1026e21 提交 2026-10-28 14:44:59 +08:00
liuyihe f3d57869f2 费用提交 2026-10-28 13:57:34 +08:00
liuyihe 7086713fdd Merge remote-tracking branch 'origin/test' into test 2026-10-28 13:42:18 +08:00
liuyihe 66b0935b11 费用提交 2026-10-28 13:42:03 +08:00
ouyangxq 4f2d90b490 完善预约货物资料与可选确认时间并修复排车衔接 2026-10-28 11:57:09 +08:00
liuyihe 96ffc0925a 费用提交 2026-10-28 11:29:38 +08:00
ouyangxq ca61f79b6d 修正外箱快照清空并提供当前计费量只读预览 2026-10-28 11:03:44 +08:00
ouyangxq b2e4f0d246 统一设备与人工测量默认算法和外箱计费快照 2026-10-28 10:30:40 +08:00
ouyangxq 0b13c2cfb7 修正排车照片端口与车次模型依赖合同 2026-10-28 10:26:52 +08:00
330 changed files with 9436 additions and 820 deletions
+1 -1
View File
@@ -55,7 +55,7 @@
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` | | 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` | | 海外业务费用来源跨区入池、人工费用与自动仓租 | `lastleg-service/`(`financefee/`、`storagerent/`)、`common-event-contract/`、`finance/` |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | | 供应商、国内整柜库存与出入库 | `warehouse/` |
+15
View File
@@ -1,9 +1,19 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
散板收货闭环:`migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql` 为已有 Lastleg 库增加入仓方式、收货仓、联系人、预计到仓、物流号及转运关联字段;新库使用同步后的海外统一基线。IAM 使用 `migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql` 补齐“散板需求”菜单及“加入散板货物”按钮,并为既有平台管理员补取货排车创建权限;权限源同步于 `iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql` 和国内统一基线。两项已在本机应用并通过两场景HTTP验收,未据此认定TEST或生产已部署;已有库不可重放空库基线,V37执行前须核对九个新增字段尚不存在。
`migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql` 修复海外人工费用资金重试报 S0500:仅将 `baiyue_lastleg.ll_finance_fee_command.action` 扩为 `VARCHAR(32)`,容纳 20 字符的 `MANUAL_FUNDING_RETRY`,保留中文备注、非空及原幂等唯一键。Lastleg 源基线和海外空库基线同步。已有环境先核对目标库及列定义,经授权执行 V35、回查列长度和记录总数,再通过原费用重试入口验证;可重复执行,不修改费用、审核、余额或资金记录,不重放空库基线。DDL 隐式提交,遇错停止;本次仅准备修复文件,尚未执行测试环境 SQL 或部署,文件存在不表示已经执行。
`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。 `overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 `finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
`finance-reconciliation-line-uniqueness-repair.sql` 修正 TEST 作废客户对账单后原费用无法重新生成对账单的问题:以一条原子 ALTER 将 `baiyue_finance.fin_bill_line` 的费用版本全历史唯一键改为单内唯一键,并保留费用来源普通索引。作废明细和金额快照完整保留;有效费用占用继续由 Finance 生成时的行锁、AVAILABLE → LOCKED 状态校验及作废单重开校验保证。模块基线与统一空库基线同步。先核对上述服务校验及目标表结构,经单独授权执行,再核对结构和记录总数;遇错停止,结果不明确先回查,不盲目重放,不更新业务数据或重放空库基线。脚本存在不表示已执行。
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。 `overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。 `overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。
@@ -16,6 +26,8 @@
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
船节点日志在 `fl_carrier_schedule_node_fact.operator_id/operator_name` 保存操作时的账号与用户名快照;人工补录和确认出仓写入,船司自动采集为空。两列定义同步于 Firstleg 船节点模块基线与国内统一空库基线,更新结构后配套发布 Firstleg 与 Admin。既有 TEST 的字段变更须单独授权执行,不重放空库初始化,不回填历史节点。
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。 `overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。 `overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
@@ -218,3 +230,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。 采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
@@ -0,0 +1,10 @@
-- 仅用于单独授权的 TEST:修正作废对账单历史明细阻止原费用重新生成对账单的问题。
-- 固定目标 MySQL 8 / InnoDB;保留全部账单、明细、金额及修订,不更新、删除或回填业务记录。
-- 单内同一费用版本保持唯一;跨单有效占用由 Finance 生成时的费用行锁和状态校验保证。
-- 前置:Finance 已具备生成时 AVAILABLE -> LOCKED 校验,以及作废单重开时的来源重新占用校验。
-- 执行前后核对 SHOW CREATE TABLE 与记录总数;本文件存在不表示已经执行。
-- 使用独立连接并开启遇错停止;一条原子 ALTER 替换索引,结果不明确先回查,不盲目重放。
ALTER TABLE baiyue_finance.fin_bill_line
DROP INDEX uk_fin_bill_line_source,
ADD UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
ADD KEY idx_fin_bill_line_source (source_type, source_id, source_version);
@@ -0,0 +1,13 @@
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
@@ -0,0 +1,3 @@
-- 海外费用资金重试动作长度修复:兼容 MANUAL_FUNDING_RETRY 等费用幂等动作。
ALTER TABLE baiyue_lastleg.ll_finance_fee_command
MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试';
@@ -0,0 +1,10 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 预算释放查询的客户和订单号关联字段与采购单、卡车单排序规则一致
-- Preconditions: ll_order_budget_reservation 已存在;核对列长度、可空性及索引
-- Postconditions: customer_id、order_no 使用 utf8mb4_unicode_ci;不修改任何业务数据
-- DDL 非事务,执行前核对目标库;失败后检查列定义再继续。
ALTER TABLE baiyue_lastleg.ll_order_budget_reservation
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离标识',
MODIFY COLUMN order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '成功创建的业务单号';
@@ -0,0 +1,10 @@
ALTER TABLE ll_local_looseboard_order
ADD COLUMN delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货',
ADD COLUMN operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID',
ADD COLUMN destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址',
ADD COLUMN destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编',
ADD COLUMN delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人',
ADD COLUMN delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话',
ADD COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
ADD COLUMN tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号',
ADD COLUMN transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号';
@@ -0,0 +1,83 @@
-- 海外费用菜单定向修复,MySQL 8.0+,目标只能 baiyue_iam。
-- 将“海外费用管理”改为“海外费用明细”,并挂到“财务管理”下。
-- 权限码、路由和角色授权保持不变;可重复执行。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS repair_overseas_fee_menu_parent;
DELIMITER $$
CREATE PROCEDURE repair_overseas_fee_menu_parent()
BEGIN
DECLARE v_menu_changed INT DEFAULT 0;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
IF (SELECT COUNT(*) FROM iam_permission
WHERE id=5105000 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:finance'
AND status='ACTIVE' AND deleted=0)<>1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Finance parent menu platform:finance is missing or inactive';
END IF;
IF EXISTS (SELECT 1 FROM iam_permission
WHERE (id=5117200 AND (permission_code<>'platform:overseas-fee:list'
OR tenant_id<>0 OR app_code<>'PLATFORM' OR permission_type<>'MENU'))
OR (permission_code='platform:overseas-fee:list'
AND (id<>5117200 OR tenant_id<>0 OR app_code<>'PLATFORM'
OR permission_type<>'MENU'))) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Overseas fee menu permission identity conflict';
END IF;
IF (SELECT COUNT(*) FROM iam_permission
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list')<>1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Overseas fee menu platform:overseas-fee:list is missing';
END IF;
START TRANSACTION;
UPDATE iam_permission
SET parent_id=5105000,
permission_name='海外费用明细',
route_path='/overseas-business/fees',
http_method='GET',
sort_no=60,
status='ACTIVE',
deleted=0,
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list'
AND (parent_id<>5105000 OR permission_name<>'海外费用明细'
OR route_path<>'/overseas-business/fees' OR http_method<>'GET'
OR sort_no<>60 OR status<>'ACTIVE' OR deleted<>0);
SET v_menu_changed=ROW_COUNT();
-- 菜单实际变化时递增已获该菜单权限用户的安全版本,刷新旧授权快照。
IF v_menu_changed>0 THEN
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
SELECT DISTINCT u.auth_user_id,1
FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0
JOIN iam_role r ON r.id=mr.role_id AND r.tenant_id=0
JOIN iam_role_permission rp ON rp.role_id=r.id
AND rp.permission_id=5117200 AND rp.tenant_id=0
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
AND m.status='ACTIVE' AND m.deleted=0
AND r.app_code='PLATFORM' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
END IF;
COMMIT;
END$$
DELIMITER ;
CALL repair_overseas_fee_menu_parent();
DROP PROCEDURE repair_overseas_fee_menu_parent;
SELECT id,parent_id,permission_code,permission_name,route_path,sort_no
FROM iam_permission WHERE id IN (5105000,5117200) ORDER BY id;
@@ -0,0 +1,29 @@
-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
@@ -0,0 +1,13 @@
-- 在 Lastleg 所属库执行;只补充整托计租截止日,不改写既有费用、库存、冻结或资金流水。
-- 发布新服务前暂停仓租出库,取消未消费预占;旧比例费用的处理需按业务单核对后单独办理。
USE baiyue_lastleg;
SET @return_rent_ddl = (
SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'll_storage_rent_lot' AND column_name = 'charged_days'
), 'SELECT 1',
'ALTER TABLE ll_storage_rent_lot ADD COLUMN charged_days BIGINT NOT NULL DEFAULT 0 COMMENT ''退货原托已生成仓租的截止库龄天数,同一天不重复计费'' AFTER original_units')
);
PREPARE return_rent_statement FROM @return_rent_ddl;
EXECUTE return_rent_statement;
DEALLOCATE PREPARE return_rent_statement;
@@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称', location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用', source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id bigint unsigned DEFAULT NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name varchar(128) DEFAULT NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -4763,7 +4765,8 @@ CREATE TABLE fin_bill_line (
currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type,source_id,source_version), UNIQUE KEY uk_fin_bill_line_bill_source (bill_id,source_type,source_id,source_version),
KEY idx_fin_bill_line_source (source_type,source_id,source_version),
KEY idx_fin_bill_line_bill (tenant_id,bill_id), KEY idx_fin_bill_line_bill (tenant_id,bill_id),
CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)), CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)),
CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')), CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')),
@@ -8462,6 +8465,20 @@ CREATE TABLE wh_document_sequence (
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。 -- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
USE baiyue_iam; USE baiyue_iam;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
USE baiyue_iam;
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
+37 -2
View File
@@ -28,6 +28,7 @@ CREATE TABLE ll_cargo_discard_application (
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希',
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
created_by_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '申请人姓名快照',
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
@@ -72,6 +73,7 @@ CREATE TABLE ll_cargo_discard_item (
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID', inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组', carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子', carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
actual_carton_marks_json JSON NULL COMMENT 'PDA现场实际废弃箱唛清单,与客户选箱快照独立保存',
available_packages_snapshot int NOT NULL COMMENT '可用箱数快照', available_packages_snapshot int NOT NULL COMMENT '可用箱数快照',
discard_packages int NOT NULL COMMENT '申请废弃箱数', discard_packages int NOT NULL COMMENT '申请废弃箱数',
completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组', completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组',
@@ -917,7 +919,7 @@ CREATE TABLE ll_return_carton_stock_receipt (
CREATE TABLE ll_finance_fee_command ( CREATE TABLE ll_finance_fee_command (
id bigint NOT NULL COMMENT '幂等命令ID', id bigint NOT NULL COMMENT '幂等命令ID',
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
action varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试',
idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键',
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256', request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256',
source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID', source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID',
@@ -1599,6 +1601,15 @@ CREATE TABLE ll_local_looseboard_order (
id bigint NOT NULL COMMENT '主键ID', id bigint NOT NULL COMMENT '主键ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID', lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货',
operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID',
destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址',
destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编',
delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人',
delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号',
transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设', transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
@@ -2105,6 +2116,8 @@ CREATE TABLE ll_overseas_procurement_order (
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
@@ -4783,6 +4796,7 @@ CREATE TABLE ll_storage_rent_lot (
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量', original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量', remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数', original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
charged_days bigint NOT NULL DEFAULT 0 COMMENT '退货原托已生成仓租的截止库龄天数,同一天不重复计费',
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间', inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
created_at datetime(3) NOT NULL COMMENT '创建时间', created_at datetime(3) NOT NULL COMMENT '创建时间',
updated_at datetime(3) NOT NULL COMMENT '更新时间', updated_at datetime(3) NOT NULL COMMENT '更新时间',
@@ -4897,4 +4911,25 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
PRIMARY KEY (order_id,command_key) PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables; CREATE TABLE ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 223 AS initialized_tables;
View File
@@ -14,6 +14,8 @@ enum BusinessFileCategory {
OVERSEAS_DISPATCH_VEHICLE_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), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -82,7 +84,8 @@ enum BusinessFileCategory {
&& tenantId != 0) { && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
} }
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE) if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE
|| this == OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE || this == OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE)
&& tenantId != 0) { && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
} }
@@ -0,0 +1,19 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class LooseboardTransportPhotoFileTest {
/** 两节点文件独立归属原排车,客户文件及PDF不能充当现场照片。 */
@Test void photosHaveDistinctPlatformOwnershipAndImagePolicy(){
var pickup=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE");
var arrival=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE");
assertThat(pickup).isNotEqualTo(arrival);
for(var category:new BusinessFileCategory[]{pickup,arrival}){
category.validateOwner(0);assertThatThrownBy(()->category.validateOwner(100)).hasMessageContaining("平台");
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
assertThat(category.storageBusinessId("100")).isEqualTo("100");
assertThatThrownBy(()->category.storageBusinessId("../100")).isInstanceOf(RuntimeException.class);
}
}
}
@@ -148,7 +148,10 @@ public class BusinessFeeSourceEventService {
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId"); positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用"); if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
walletIdentity(value); walletIdentity(value);
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) { } else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())
// 新采购主单复用人工收费,只有显式账单结算可入池;旧采购和余额费用仍必须引用实扣。
&& !("LASTLEG".equals(value.sourceSystem()) && "OVERSEAS_PROCUREMENT".equals(value.businessType())
&& "BILL".equals(value.settlementMode()))) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池"); throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
} }
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) { if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
@@ -279,8 +282,7 @@ public class BusinessFeeSourceEventService {
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem())) if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
throw error("FIN-EVT-017", "资金业务引用不完整"); throw error("FIN-EVT-017", "资金业务引用不完整");
if ("OVERSEAS_MANUAL_FEE".equals(type)) { if ("OVERSEAS_MANUAL_FEE".equals(type)) {
if ("FEE_TYPE_077".equals(source.feeItemCode()) if (!no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配"); throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
} else if ("STORAGE_RENT".equals(type)) { } else if ("STORAGE_RENT".equals(type)) {
if (!"FEE_TYPE_077".equals(source.feeItemCode())) if (!"FEE_TYPE_077".equals(source.feeItemCode()))
@@ -363,17 +363,30 @@ public class FinanceExportProviderService {
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(), "receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(),
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt()); "remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
} }
/** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */
private Map<String, Object> settlementRow(SettlementSummary value) { private Map<String, Object> settlementRow(SettlementSummary value) {
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(), return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(), "currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", value.status()); "businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", settlementStatusLabel(value.status()));
} }
/** 明细导出与列表保持相同的中文结算状态。 */
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) { private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(), return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(),
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(), "feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(), "containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(), "lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", summary.status()); "outstandingAmount", summary.outstandingAmount(), "status", settlementStatusLabel(summary.status()));
}
/** 转换正式结算状态,未知值保留原值以便排查。 */
private String settlementStatusLabel(String status) {
if (status == null) return null;
return switch (status) {
case "ISSUED" -> "已出账";
case "PARTIALLY_PAID" -> "部分结清";
case "PAID" -> "已结清";
case "VOIDED" -> "已作废";
default -> status;
};
} }
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) { private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(), return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
+2 -1
View File
@@ -225,7 +225,8 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version), UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
KEY idx_fin_bill_line_source (source_type, source_id, source_version),
KEY idx_fin_bill_line_bill (tenant_id, bill_id), KEY idx_fin_bill_line_bill (tenant_id, bill_id),
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0), CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
@@ -328,6 +328,53 @@ class BusinessFeeSourceEventServiceTest {
noFinancialWrites(); noFinancialWrites();
} }
/** 新采购主单只有明确选择账单时才可作为未收应收入池。 */
@Test
void manualProcurementExplicitBillCanEnterReceivablePool() {
EventEnvelope event = manualProcurementEvent("BILL");
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
}
/** 采购主单的余额或默认模式不能因兼容账单而绕过实扣校验。 */
@ParameterizedTest
@org.junit.jupiter.params.provider.NullSource
@ValueSource(strings = {"WALLET"})
void manualProcurementWithoutConsumptionCannotEnterPool(String mode) {
assertThatThrownBy(() -> service.consume(manualProcurementEvent(mode))).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
/** 保留旧采购子费用的扣款约束,即使载荷声明账单结算也不能入池。 */
@Test
void legacyProcurementCannotBypassConsumptionWithBillMode() {
EventEnvelope base = procurementEvent(false);
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("settlementMode", "BILL");
assertThatThrownBy(() -> service.consume(resignProcurementEvent(base, payload))).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
private EventEnvelope manualProcurementEvent(String mode) {
EventEnvelope base = procurementEvent(false);
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("businessType", "OVERSEAS_PROCUREMENT");
if (mode == null) payload.putNull("settlementMode"); else payload.put("settlementMode", mode);
return resignProcurementEvent(base, payload);
}
private EventEnvelope resignProcurementEvent(EventEnvelope base, ObjectNode payload) {
payload.putNull("action"); payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", "PUBLISHED");
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@Test @Test
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() { void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
EventEnvelope event = procurementEvent(true); EventEnvelope event = procurementEvent(true);
@@ -375,6 +422,12 @@ class BusinessFeeSourceEventServiceTest {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071"); verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
} }
/** 手动仓租保留人工费用资金身份,仍校验原冻结与实扣来源。 */
@Test
void manualStorageRentConsumptionUsesManualFundingIdentity() {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_077");
}
@Test @Test
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() { void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077"); verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
@@ -0,0 +1,66 @@
package com.baiyue.finance;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FinanceReconciliationSchemaContractTest {
/** 执行真实明细表约束,复现两笔费用作废后重新出单,保留原单快照且单内不能重复。 */
@ParameterizedTest
@ValueSource(booleans = {false, true})
void voidedStatementKeepsHistoryWhileItsTwoSourcesCanEnterANewStatement(boolean unified) throws Exception {
JdbcTemplate jdbc = createSchema(unified);
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT NOT NULL,status VARCHAR(32) NOT NULL)");
jdbc.update("INSERT INTO fin_bill VALUES(501,9,'ISSUED'),(502,9,'DRAFT')");
insertLine(jdbc, 1, 501, 88);
insertLine(jdbc, 2, 501, 89);
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=501");
insertLine(jdbc, 3, 502, 88);
insertLine(jdbc, 4, 502, 89);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=501", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=502", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT status FROM fin_bill WHERE id=501", String.class)).isEqualTo("VOIDED");
assertThat(jdbc.queryForObject("SELECT SUM(l.amount) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id WHERE b.status<>'VOIDED'", BigDecimal.class))
.isEqualByComparingTo("200.00");
assertThatThrownBy(() -> insertLine(jdbc, 5, 502, 88)).isInstanceOf(DuplicateKeyException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line", Integer.class)).isEqualTo(4);
}
/** 从模块或统一空库入口读取真实建表语句,仅移除 H2 不支持的 MySQL 存储语法。 */
private static JdbcTemplate createSchema(boolean unified) throws Exception {
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
if (!Files.exists(unifiedPath)) unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String sql = Files.readString(unified ? unifiedPath : Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_bill_line \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql);
assertThat(table.find()).isTrue();
String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");")
.replaceAll("(?i) COMMENT '(?:[^']|'')*'", "")
.replaceAll("(?i)\\bUNSIGNED\\b|_utf8mb4", "")
.replaceAll("(?m)^\\s*KEY [^\\r\\n]+\\r?\\n", "")
.replaceAll("UNIQUE KEY (\\w+)", "CONSTRAINT $1 UNIQUE");
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"));
jdbc.execute(ddl);
return jdbc;
}
/** 写入完整合成明细,仅改变本测试关注的单据和费用来源身份。 */
private static void insertLine(JdbcTemplate jdbc, long id, long billId, long sourceId) {
jdbc.update("INSERT INTO fin_bill_line(id,tenant_id,bill_id,source_type,source_id,source_version,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,9,?,'FIRSTLEG_FEE',?,1,'SYNTHETIC','合成测试费用',100,'CNY')",
id, billId, sourceId);
}
}
@@ -126,6 +126,22 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)); verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
} }
/** 已被占用的费用不能完成第二张有效对账单的生成,事务必须拒绝提交。 */
@Test
void createRejectsSourceWhoseAvailableStatusCannotBeLocked() throws Exception {
JdbcTemplate jdbc = createJdbc();
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)))
.thenReturn(0);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
assertThatThrownBy(() -> serviceWithNumber(jdbc).create(71L, "create-key", request))
.isInstanceOf(FinanceEventException.class)
.hasMessage("费用来源已被其他对账单占用")
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any());
}
@Test @Test
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception { void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
@@ -428,6 +444,24 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
} }
/** 作废单的两笔原费用有任一已进入新单时,旧单不得重开为另一张有效单。 */
@ParameterizedTest
@ValueSource(ints = {0, 1})
void reopenRejectsVoidedStatementWhenReplacementOccupiesAnySource(int reacquired) throws Exception {
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-conflict-key");
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L)))
.thenReturn(2);
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)))
.thenReturn(reacquired);
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "reopen-conflict-key",
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of())))
.isInstanceOf(FinanceEventException.class)
.hasMessage("原费用来源已被占用,无法重开对账单")
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any());
}
@Test @Test
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() { void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of()); ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
@@ -45,6 +45,26 @@ class FinanceExportProviderServiceTest {
service.initializeSigningKey(); service.initializeSigningKey();
} }
@Test
/** 应付结算列表和详情导出均使用中文状态,包括无明细的单据。 */
void exportsSettlementStatusesInChinese() {
for (var entry : Map.of("ISSUED", "已出账", "PARTIALLY_PAID", "部分结清", "PAID", "已结清", "VOIDED", "已作废").entrySet()) {
var summary = new com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary(
701L, "AP-701", "PAYABLE", 10001L, "客户A", 301L, "供应商A", null, null,
"2026-11", "操作费", "CNY", BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN,
LocalDate.parse("2026-11-03"), null, "UNPAID", entry.getKey(), 7L, "财务员", null, null, null, 1);
when(settlementService.listSettlements("PAYABLE", null)).thenReturn(List.of(summary));
when(settlementService.getSettlement(701L)).thenReturn(new com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail(
summary, List.of(), List.of(), List.of()));
var list = service.exportBatch(FinanceExportProviderService.PAYABLE_SETTLEMENT_LIST,
request(Map.of("status", entry.getKey()), null, null));
var detail = service.exportBatch(FinanceExportProviderService.PAYABLE_SETTLEMENT_DETAIL,
request(Map.of("settlementId", "701"), null, null));
assertThat(list.rows().get(0)).containsEntry("status", entry.getValue());
assertThat(detail.rows().get(0)).containsEntry("status", entry.getValue());
}
}
@Test @Test
void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() { void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() {
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"), Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
+1 -1
View File
@@ -33,7 +33,7 @@
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” | | 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” | | 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” | | FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” |
| 费用项目、价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`pricebookquery/application/PriceBookQueryService.java`;查“价格簿查询内部切片” | | 费用项目、共享费用目录与价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`controller/InternalFeeCatalogController.java`、`pricebookquery/application/PriceBookQueryService.java`;共享目录契约 `src/main/resources/openapi/fee-catalog-internal-v1.yaml`,价格簿查“价格簿查询内部切片” |
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” | | 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” | | 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
| 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” | | 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” |
+3 -3
View File
@@ -30,8 +30,8 @@
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | | 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认 `ACTUAL` 不调整尺寸,可启用的调整算法为 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;私人地址 `PRIVATE` 使用 `/fees/manual-quote` 生成人工头程报价,按客户计费方式计算计费量且附加费为零,预览返回 `priceMatched=false`,报价后沿用费用版本、幂等与预装前初始费用校验;`POST /fees/preview` 与正式计价共用算法,只读返回当前选择值的计费量、参考单价和合计,不生成费用版本或改变履约、财务状态 |
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | | 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | | 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | | 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
@@ -76,7 +76,7 @@
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 - 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 - 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 - 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ACTUAL` 不调整尺寸,仓库修正值替换对应实测边;`ADD` 以修正后的实测尺寸为基准对三个维度分别增加命中箱规的尺寸值;`MULTIPLY` 使用选定的启用系数配置,未指定配置时取当前客户唯一默认配置,由服务端按规格档位选择系数并分别作用于长宽高,体积按系数三次方变化。设备与人工录入共用计算入口,行明确选择的算法优先,否则仅使用客户默认算法;预置或保存系数配置不等于启用该算法。测量行必须保存实际尺寸、选用规则和算法快照,修改确认箱数复用已保存单箱快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
@@ -37,6 +37,16 @@ import java.security.MessageDigest;
@RestController @RestController
@RequestMapping("/internal/firstleg/v1/inbounds") @RequestMapping("/internal/firstleg/v1/inbounds")
public class InternalInboundOrderController { public class InternalInboundOrderController {
/** 海外废弃溯源只读取已生成的有效箱唛,不触发打印或补建。 */
@org.springframework.web.bind.annotation.GetMapping("/{inboundId}/existing-carton-marks")
public ApiResponse<List<String>> existingCartonMarks(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@org.springframework.web.bind.annotation.PathVariable long inboundId,
@org.springframework.web.bind.annotation.RequestParam long customerId,
@org.springframework.web.bind.annotation.RequestParam String inboundCode) {
requireToken(token);
return ApiResponse.success(cartonLabels.existingCartonMarks(customerId, inboundId, inboundCode));
}
@Resource @Resource
private InboundOrderCreationService service; private InboundOrderCreationService service;
@Resource @Resource
@@ -7,6 +7,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
import com.baiyue.firstleg.service.FbaPricingService; import com.baiyue.firstleg.service.FbaPricingService;
@@ -34,6 +36,16 @@ public class PlatformFbaPricingController {
@Resource @Resource
private FbaPricingService service; private FbaPricingService service;
/** 预览当前重量、体积对应的计费量和价格,不生成费用版本或改变履约状态。 */
@PostMapping("/preview")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')")
public ApiResponse<FeePreviewResponse> preview(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @Valid @RequestBody FeePreviewRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.preview(scope, inboundId, request));
}
@PostMapping("/calculate") @PostMapping("/calculate")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')") @PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId", @BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
@@ -35,6 +35,13 @@ public interface ContainerCustomsMapper {
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy, f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
"""; """;
String CONTAINER_COLUMNS = """
id id,loading_plan_id loadingPlanId,container_booking_id containerBookingId,status status,
started_by startedBy,started_by_name startedByName,started_at startedAt,
completed_by completedBy,completed_by_name completedByName,completed_at completedAt,
broker_submitted_by brokerSubmittedBy,broker_submitted_by_name brokerSubmittedByName,
broker_submitted_at brokerSubmittedAt,created_at createdAt,updated_at updatedAt,version version
""";
@Select(""" @Select("""
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
@@ -132,19 +139,19 @@ public interface ContainerCustomsMapper {
int insertContainer(@Param("id") long id,@Param("planId") long planId, int insertContainer(@Param("id") long id,@Param("planId") long planId,
@Param("containerBookingId") long containerBookingId,@Param("status") String status); @Param("containerBookingId") long containerBookingId,@Param("status") String status);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId}")
ContainerRow container(@Param("planId") long planId); ContainerRow container(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id}")
ContainerRow containerById(@Param("id") long id); ContainerRow containerById(@Param("id") long id);
@Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId); ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
ContainerRow lockContainer(@Param("planId") long planId); ContainerRow lockContainer(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
ContainerRow lockContainerById(@Param("id") long id); ContainerRow lockContainerById(@Param("id") long id);
@Update(""" @Update("""
@@ -76,6 +76,25 @@ public final class FbaPricingDtos {
} }
} }
/** 当前输入的只读预览,人工单价仅用于预览金额,不写入费用。 */
public record FeePreviewRequest(
LocalDateTime pricingAt,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@Size(max = 32) String weightSource,
@Size(max = 32) String volumeSource,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedUnitPrice
) {}
/** 当前选择值的只读计费预览,不包含费用版本或持久化标识。 */
public record FeePreviewResponse(
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal unitPrice, BigDecimal additionalAmount, BigDecimal totalAmount,
String currency, boolean priceMatched
) {}
public record ManualQuoteRequest( public record ManualQuoteRequest(
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.entity; package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter; import lombok.Getter;
@@ -34,6 +36,7 @@ public class InboundMeasurementItemEntity {
private BigDecimal volumeCbm; private BigDecimal volumeCbm;
private BigDecimal totalVolumeCbm; private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg; private BigDecimal volumeWeightKg;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long chargeRuleId; private Long chargeRuleId;
private String coefficientMode; private String coefficientMode;
private String calculationRuleVersion; private String calculationRuleVersion;
@@ -44,8 +47,12 @@ public class InboundMeasurementItemEntity {
private BigDecimal outerLengthCm; private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm; private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm; private BigDecimal outerHeightCm;
// 清空修正表示恢复对应实测边,必须显式写入 NULL,不能被默认的非空更新策略忽略。
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterLengthCm; private BigDecimal manualOuterLengthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterWidthCm; private BigDecimal manualOuterWidthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterHeightCm; private BigDecimal manualOuterHeightCm;
private String remark; private String remark;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
String providerNodeCode, String standardNode, String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} String sourceReference, String rawPayloadHash, LocalDateTime createdAt,
String operatorName) {}
public record ContainerNodeDetail(Long containerBookingId, String currentNode, public record ContainerNodeDetail(Long containerBookingId, String currentNode,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {} LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
private String locationName; private String locationName;
private String sourceReference; private String sourceReference;
private String rawPayloadHash; private String rawPayloadHash;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
} }
@@ -11,14 +11,15 @@ import java.util.List;
@Mapper @Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> { public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
/** 保存不可变节点事实及执行该操作的账号快照。 */
@Insert(""" @Insert("""
INSERT INTO fl_carrier_schedule_node_fact INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at, (id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,operator_id,operator_name,created_at)
VALUES VALUES
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode}, (#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
CURRENT_TIMESTAMP(3)) CURRENT_TIMESTAMP(3))
""") """)
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value); int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
public long customerId(long id) { return required(id).getCustomerId(); } public long customerId(long id) { return required(id).getCustomerId(); }
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ /** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
@Transactional @Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId, public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId, long containerBookingId,
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
persistenceStage = "NODE_FACT"; persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId)); sha256("OUTBOUND_CONFIRM|" + containerBookingId), operatorId, operatorName);
if (departed != null) { if (departed != null) {
persistenceStage = "CROSS_REGION_OUTBOX"; persistenceStage = "CROSS_REGION_OUTBOX";
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), ""); + ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
} }
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
@Transactional @Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) { public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleQueryEntity value = requiredLocked(id);
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(), CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node, request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash()); request.locationName(), request.sourceReference(), request.rawPayloadHash(), null, null);
if (fact == null) { if (fact == null) {
return detail(value); return detail(value);
} }
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
return detail(value); return detail(value);
} }
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
@Transactional @Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key, public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) { ManualNodeRequest request) {
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node, CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(), request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request)); "MANUAL:" + operatorId, hash(operation, id, request), operatorId, operatorName);
if (fact == null) { if (fact == null) {
return complete(id, operatorId, key, detail(value)); return complete(id, operatorId, key, detail(value));
} }
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId)); return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
} }
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
@Transactional @Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId, public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key, String operatorName, String key,
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId, CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId, "MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash); request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash,
operatorId, operatorName);
if (fact != null && query != null) { if (fact != null && query != null) {
CarrierScheduleQueryEntity before = copy(query); CarrierScheduleQueryEntity before = copy(query);
requireManualActualAdvance(query, node, request.actualAt()); requireManualActualAdvance(query, node, request.actualAt());
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
repository.histories(value.getId()).stream().map(this::history).toList()); repository.histories(value.getId()).stream().map(this::history).toList());
} }
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
private NodeFact node(CarrierScheduleNodeFactEntity value) { private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(), return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt()); value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
} }
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) { private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
} }
/** 为查询任务追加节点事实并透传人工操作账号。 */
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId, private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId, return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName, providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash); sourceReference, rawPayloadHash, operatorId, operatorName);
} }
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId, private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode, String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId); fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
@@ -589,6 +596,7 @@ public class CarrierScheduleQueryService {
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
if (repository.insertIfAbsent(fact)) return fact; if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey()); CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) { if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
@@ -44,21 +44,22 @@ public class CartonMeasurementService {
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider; private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
private final MeasurementImageClient imageClient; private final MeasurementImageClient imageClient;
private final TransactionTemplate transactionTemplate; private final TransactionTemplate transactionTemplate;
private final CustomerDimensionWeightingService dimensionWeighting; private final MeasurementBatchService measurementBatchService;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
/** 设备及手工单箱录入复用测量批次的计算规则。 */
public CartonMeasurementService(InboundMeasurementRepository repository, public CartonMeasurementService(InboundMeasurementRepository repository,
ObjectProvider<CartonLabelBindingResolver> resolverProvider, ObjectProvider<CartonLabelBindingResolver> resolverProvider,
MeasurementImageClient imageClient, MeasurementImageClient imageClient,
TransactionTemplate transactionTemplate, TransactionTemplate transactionTemplate,
CustomerDimensionWeightingService dimensionWeighting) { MeasurementBatchService measurementBatchService) {
this.repository = repository; this.repository = repository;
this.resolverProvider = resolverProvider; this.resolverProvider = resolverProvider;
this.imageClient = imageClient; this.imageClient = imageClient;
this.transactionTemplate = transactionTemplate; this.transactionTemplate = transactionTemplate;
this.dimensionWeighting = dimensionWeighting; this.measurementBatchService = measurementBatchService;
} }
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) { public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
@@ -178,6 +179,7 @@ public class CartonMeasurementService {
return result(measurement, item, false); return result(measurement, item, false);
} }
/** 保存设备原始尺寸与证据,按客户默认算法或已保存的行算法生成计费快照。 */
private UpsertOutcome persist(long tenantId, long operatorId, private UpsertOutcome persist(long tenantId, long operatorId,
CartonLabelBindingResolver.Binding binding, CartonLabelBindingResolver.Binding binding,
String boxCode, CartonMeasurementCommand command, long imageFileId, String boxCode, CartonMeasurementCommand command, long imageFileId,
@@ -228,7 +230,7 @@ public class CartonMeasurementService {
item.setMachineNo(command.machineNo().trim()); item.setMachineNo(command.machineNo().trim());
item.setDeviceMeasuredAt(command.measuredAt()); item.setDeviceMeasuredAt(command.measuredAt());
item.setUpdatedAt(LocalDateTime.now()); item.setUpdatedAt(LocalDateTime.now());
applyCalculatedValues(item, dimensionWeighting.load(tenantId)); measurementBatchService.calculateCartonDimensions(item);
if (created) { if (created) {
repository.insertItem(item); repository.insertItem(item);
@@ -265,33 +267,12 @@ public class CartonMeasurementService {
return measurement; return measurement;
} }
private void applyCalculatedValues(InboundMeasurementItemEntity item, /** 仅修改确认箱数时复用已保存的单箱快照,不重新套用当前客户规则。 */
CustomerDimensionWeightingService.RuleSet dimensionRules) {
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolveDeviceMeasurement(
item.getLengthCm(), item.getWidthCm(), item.getHeightCm());
BigDecimal unitRaw = weighted.rawVolumeCbm();
BigDecimal unitVolume = weighted.unitVolumeCbm();
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.UNNECESSARY);
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
item.setOuterLengthCm(weighted.outerLengthCm());
item.setOuterWidthCm(weighted.outerWidthCm());
item.setOuterHeightCm(weighted.outerHeightCm());
}
private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) { private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) {
if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm()) if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm())
|| !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm()) || !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm())
|| !StringUtils.hasText(item.getCalculationRuleVersion())) { || !StringUtils.hasText(item.getCalculationRuleVersion())) {
applyCalculatedValues(item, dimensionWeighting.load(item.getTenantId())); measurementBatchService.calculateCartonDimensions(item);
return; return;
} }
BigDecimal count = BigDecimal.valueOf(item.getPackageCount()); BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
@@ -25,6 +25,7 @@ public class CustomerDimensionWeightingService {
this.repository = repository; this.repository = repository;
} }
/** 加载完整四档箱规及客户明确选择的默认算法,配置存在不代表算法已启用。 */
public RuleSet load(long tenantId) { public RuleSet load(long tenantId) {
List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId); List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId);
if (values == null || values.size() != CustomerDimensionBoxType.values().length if (values == null || values.size() != CustomerDimensionBoxType.values().length
@@ -33,7 +34,14 @@ public class CustomerDimensionWeightingService {
.collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) { .collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积"); throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积");
} }
return new RuleSet(values.stream().map(Rule::from).toList()); List<String> modes = values.stream().map(value -> value.getDefaultCalculationMode() == null
|| value.getDefaultCalculationMode().isBlank() ? "ACTUAL"
: value.getDefaultCalculationMode().trim().toUpperCase(java.util.Locale.ROOT))
.distinct().toList();
if (modes.size() != 1 || !List.of("ACTUAL", "ADD", "MULTIPLY").contains(modes.get(0))) {
throw new BizException(ResultCode.CONFLICT, "客户默认箱规计算方式配置不一致或无效");
}
return new RuleSet(values.stream().map(Rule::from).toList(), modes.get(0));
} }
private static boolean invalidRule(CustomerDimensionConfigEntity value) { private static boolean invalidRule(CustomerDimensionConfigEntity value) {
@@ -48,14 +56,22 @@ public class CustomerDimensionWeightingService {
public static final class RuleSet { public static final class RuleSet {
private final List<Rule> rules; private final List<Rule> rules;
private final String defaultCalculationMode;
private RuleSet(List<Rule> rules) { /** 保存同一客户的箱规和默认算法,供设备及人工测量统一使用。 */
private RuleSet(List<Rule> rules, String defaultCalculationMode) {
this.defaultCalculationMode = defaultCalculationMode;
this.rules = rules.stream() this.rules = rules.stream()
.sorted(Comparator.comparing(Rule::innerVolume) .sorted(Comparator.comparing(Rule::innerVolume)
.thenComparing(rule -> rule.boxType().ordinal())) .thenComparing(rule -> rule.boxType().ordinal()))
.toList(); .toList();
} }
/** 返回客户已启用的默认算法;ACTUAL 表示尺寸不调整。 */
public String defaultCalculationMode() {
return defaultCalculationMode;
}
public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) {
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm); List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
return rules.stream() return rules.stream()
@@ -66,24 +82,6 @@ public class CustomerDimensionWeightingService {
.weightedActualDimensions(lengthCm, widthCm, heightCm)); .weightedActualDimensions(lengthCm, widthCm, heightCm));
} }
public WeightedDimensions resolveDeviceMeasurement(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
return rules.stream()
.filter(rule -> rule.contains(measured))
.findFirst()
.map(rule -> rule.weightedActualDimensions(lengthCm, widthCm, heightCm))
.orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm));
}
private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm,
rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP),
"ACTUAL_DIMENSION_V1");
}
} }
public record WeightedDimensions( public record WeightedDimensions(
@@ -5,6 +5,8 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
@@ -63,6 +65,49 @@ public class FbaPricingService {
@Resource @Resource
private ContainerTierAmountAllocator containerTierAllocator; private ContainerTierAmountAllocator containerTierAllocator;
/** 按当前选择值只读预览,复用正式计价和柜级阶梯规则,不写费用、幂等或财务记录。 */
public FeePreviewResponse preview(long tenantId, long inboundId, FeePreviewRequest request) {
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
requireMeasurementSources(tenantId, inboundId, request.weightSource(), request.volumeSource());
BigDecimal weight = selectedWeight(inbound, request.selectedWeightKg());
BigDecimal volume = selectedVolume(inbound, request.selectedVolumeCbm());
PricingPolicy policy = pricingPolicy(inbound,
request.pricingAt() == null ? LocalDateTime.now(clock) : request.pricingAt());
if ("PRIVATE".equals(inbound.getDestinationType())) {
return manualPreview(weight, volume, policy, request.selectedUnitPrice());
}
try {
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
BigDecimal additional = estimate.rate().getAdditionalFee() == null ? BigDecimal.ZERO
: estimate.rate().getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal subtotal = request.selectedUnitPrice() == null
|| request.selectedUnitPrice().compareTo(estimate.unitPrice()) == 0
? estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)
: estimate.chargeableQuantity().multiply(request.selectedUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
return new FeePreviewResponse(weight, volume, estimate.chargeableQuantity(), estimate.chargeUnit(),
estimate.conversionFactor(), estimate.roundingScale(), estimate.roundingMode(),
estimate.unitPrice(), additional,
subtotal.add(additional),
estimate.currency(), true);
} catch (BizException exception) {
// 仅价格未命中且可人工定价的 FBA 返回计费量;其他业务错误继续明确拒绝。
if (exception.getResultCode() != ResultCode.NOT_FOUND || !"FBA".equals(inbound.getDestinationType())) {
throw exception;
}
return manualPreview(weight, volume, policy, request.selectedUnitPrice());
}
}
private FeePreviewResponse manualPreview(BigDecimal weight, BigDecimal volume, PricingPolicy policy,
BigDecimal selectedUnitPrice) {
BigDecimal quantity = manualChargeableQuantity(policy.chargeMode(), weight, volume);
BigDecimal amount = selectedUnitPrice == null ? null
: quantity.multiply(selectedUnitPrice).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363");
return new FeePreviewResponse(weight, volume, quantity, policy.chargeMode(), factor, 6, "HALF_UP",
null, BigDecimal.ZERO.setScale(MONEY_SCALE), amount, "CNY", false);
}
@Transactional @Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) { LocalDateTime requestedAt, String idempotencyKey) {
@@ -77,6 +122,7 @@ public class FbaPricingService {
selectedVolumeCbm, null, null, idempotencyKey); selectedVolumeCbm, null, null, idempotencyKey);
} }
/** 复用预览的计价规则生成费用版本,仅正式确认流程写入计费事实。 */
@Transactional @Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, BigDecimal selectedWeightKg, LocalDateTime requestedAt, BigDecimal selectedWeightKg,
@@ -95,6 +141,9 @@ public class FbaPricingService {
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
if ("PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("私人地址请使用人工头程报价");
}
InboundFeeEntity published = null; InboundFeeEntity published = null;
if ("SENT".equals(inbound.getFeeStatus())) { if ("SENT".equals(inbound.getFeeStatus())) {
published = repository.findLatestFeeForUpdate(tenantId, inboundId); published = repository.findLatestFeeForUpdate(tenantId, inboundId);
@@ -105,38 +154,11 @@ public class FbaPricingService {
throw conflict("客户已确认,不允许再修改或重新发送费用"); throw conflict("客户已确认,不允许再修改或重新发送费用");
} }
} }
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); var measurement = requireMeasurementSources(tenantId, inboundId, weightSource, volumeSource);
boolean actualMeasurementSource = "ACTUAL".equalsIgnoreCase(weightSource)
|| "ACTUAL".equalsIgnoreCase(volumeSource);
boolean outerMeasurementSource = "OUTER".equalsIgnoreCase(weightSource)
|| "OUTER".equalsIgnoreCase(volumeSource);
if ((actualMeasurementSource || outerMeasurementSource) && measurement == null) {
throw conflict(actualMeasurementSource
? "实际测量数据不存在,不能生成对客费用"
: "外箱规测量批次不存在,不能生成对客费用");
}
if (actualMeasurementSource || outerMeasurementSource) {
var measurementItems = measurementRepository.listItems(tenantId, measurement.getId());
if (actualMeasurementSource && measurementItems.isEmpty()) {
throw conflict("实际测量明细不存在,不能生成对客费用");
}
boolean incomplete = outerMeasurementSource && (measurementItems.isEmpty() || measurementItems.stream().anyMatch(item ->
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt); PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest( BigDecimal weight = selectedWeight(inbound, selectedWeightKg);
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), BigDecimal volume = selectedVolume(inbound, selectedVolumeCbm);
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
pricingTaxMode(inbound), policy.chargeMode(),
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP);
estimate = applyContainerCustomerTier(inbound, estimate, weight, volume);
RateRow rate = estimate.rate(); RateRow rate = estimate.rate();
BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable; BigDecimal converted = chargeable;
@@ -179,6 +201,7 @@ public class FbaPricingService {
return response(inbound, fee, rate.getUnitPrice(), nextStatus); return response(inbound, fee, rate.getUnitPrice(), nextStatus);
} }
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
@Transactional @Transactional
public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId,
ManualQuoteRequest request, String idempotencyKey) { ManualQuoteRequest request, String idempotencyKey) {
@@ -194,7 +217,9 @@ public class FbaPricingService {
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
if (!"FBA".equals(inbound.getDestinationType())) throw conflict("仅FBA头程运费支持未命中人工定价"); if (!"FBA".equals(inbound.getDestinationType()) && !"PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("仅FBA未命中报价或私人地址支持人工头程定价");
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
if (measurement == null || measurementRepository.listItems(tenantId, measurement.getId()).isEmpty()) { if (measurement == null || measurementRepository.listItems(tenantId, measurement.getId()).isEmpty()) {
throw conflict("实际测量明细不存在,不能生成人工头程费用"); throw conflict("实际测量明细不存在,不能生成人工头程费用");
@@ -206,6 +231,8 @@ public class FbaPricingService {
} }
LocalDateTime now = LocalDateTime.now(clock); LocalDateTime now = LocalDateTime.now(clock);
PricingPolicy policy = pricingPolicy(inbound, now); PricingPolicy policy = pricingPolicy(inbound, now);
// 私人地址使用人工报价;FBA仍须先证明价格表未命中,避免绕开有效报价。
if ("FBA".equals(inbound.getDestinationType())) {
try { try {
priceBookEstimator.estimate(tenantId, new EstimateRequest( priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
@@ -215,13 +242,12 @@ public class FbaPricingService {
} catch (BizException exception) { } catch (BizException exception) {
if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception; if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception;
} }
}
BigDecimal weight = request.selectedWeightKg().setScale(3, RoundingMode.HALF_UP); BigDecimal weight = request.selectedWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP); BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
String unit = policy.chargeMode(); String unit = policy.chargeMode();
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363"); BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
BigDecimal quantity = ("KG".equals(unit) ? weight.max(volume.multiply(factor)) BigDecimal quantity = manualChargeableQuantity(unit, weight, volume);
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
.setScale(6, RoundingMode.HALF_UP);
BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP); BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
int version = repository.nextFeeVersion(tenantId, inboundId); int version = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity fee = new InboundFeeEntity(); InboundFeeEntity fee = new InboundFeeEntity();
@@ -254,7 +280,8 @@ public class FbaPricingService {
throw conflict("费用已被其他操作员修改,请刷新后重试"); throw conflict("费用已被其他操作员修改,请刷新后重试");
} }
repository.insertFee(fee); repository.insertFee(fee);
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", subtotal, now); String feeName = "PRIVATE".equals(inbound.getDestinationType()) ? "头程基础运费" : "FBA基础运费";
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", feeName, subtotal, now);
item.setQuantity(quantity); item.setUnit(unit); item.setUnitPrice(request.unitPrice()); item.setQuantity(quantity); item.setUnit(unit); item.setUnitPrice(request.unitPrice());
item.setAutomaticFlag(false); item.setRemark(reason); item.setAutomaticFlag(false); item.setRemark(reason);
repository.insertItem(item); repository.insertItem(item);
@@ -267,6 +294,61 @@ public class FbaPricingService {
return response(inbound, fee, request.unitPrice(), nextStatus); return response(inbound, fee, request.unitPrice(), nextStatus);
} }
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
private ActivePriceBookEstimator.Estimate estimateFee(long tenantId, InboundPricingEntity inbound,
PricingPolicy policy, BigDecimal weight, BigDecimal volume) {
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), policy.chargeMode(), weight, volume));
return applyContainerCustomerTier(inbound, estimate, weight, volume);
}
/** 计费重量统一保留三位精度,预览与最终费用使用同一基数。 */
private BigDecimal selectedWeight(InboundPricingEntity inbound, BigDecimal selected) {
BigDecimal weight = selected == null ? inbound.getActualNetWeightKg() : selected;
if (weight == null || weight.signum() <= 0) throw conflict("计费重量必须大于0");
return weight.setScale(3, RoundingMode.HALF_UP);
}
/** 计费体积统一保留四位精度,预览与最终费用使用同一基数。 */
private BigDecimal selectedVolume(InboundPricingEntity inbound, BigDecimal selected) {
BigDecimal volume = selected == null ? inbound.getActualVolumeCbm() : selected;
if (volume == null || volume.signum() <= 0) throw conflict("计费体积必须大于0");
return volume.setScale(4, RoundingMode.HALF_UP);
}
/** 人工定价的重量与体积换算规则由服务端统一提供给预览和正式报价。 */
private BigDecimal manualChargeableQuantity(String unit, BigDecimal weight, BigDecimal volume) {
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
return ("KG".equals(unit) ? weight.max(volume.multiply(factor))
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
.setScale(6, RoundingMode.HALF_UP);
}
/** 仅验证测量来源及保存快照完整性,不写入或重新计算测量数据。 */
private com.baiyue.firstleg.entity.InboundMeasurementEntity requireMeasurementSources(
long tenantId, long inboundId, String weightSource, String volumeSource) {
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
boolean actual = "ACTUAL".equalsIgnoreCase(weightSource) || "ACTUAL".equalsIgnoreCase(volumeSource);
boolean outer = "OUTER".equalsIgnoreCase(weightSource) || "OUTER".equalsIgnoreCase(volumeSource);
if ((actual || outer) && measurement == null) {
throw conflict(actual ? "实际测量数据不存在,不能生成对客费用" : "外箱规测量批次不存在,不能生成对客费用");
}
if (actual || outer) {
var items = measurementRepository.listItems(tenantId, measurement.getId());
if (actual && items.isEmpty()) throw conflict("实际测量明细不存在,不能生成对客费用");
if (outer && (items.isEmpty() || items.stream().anyMatch(item ->
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|| !StringUtils.hasText(item.getCalculationRuleVersion())))) {
throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
}
return measurement;
}
private ActivePriceBookEstimator.Estimate applyContainerCustomerTier( private ActivePriceBookEstimator.Estimate applyContainerCustomerTier(
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual, InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
BigDecimal currentWeight, BigDecimal currentVolume) { BigDecimal currentWeight, BigDecimal currentVolume) {
@@ -719,8 +801,9 @@ public class FbaPricingService {
private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) { private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId); InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) { if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())
throw conflict("当前目的地类型不支持自动计费"); && !"PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持头程计费");
} }
if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) { if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) {
throw conflict("入库单缺少起运仓或渠道匹配维度"); throw conflict("入库单缺少起运仓或渠道匹配维度");
@@ -45,6 +45,16 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
@Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private InboundOrderCreationService inboundOrderCreationService;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
/** 海外按数量收货后的只读溯源:仅读已有有效批次,不生成或修改箱号。 */
public List<String> existingCartonMarks(long customerId, long inboundId, String inboundCode) {
requireIds(customerId, inboundId);
var labels = repository.findActiveByInbound(customerId, inboundId);
if (labels.isEmpty()) return List.of();
return requireConsistent(labels).stream()
.filter(label -> inboundCode.equals(label.getInboundCode()))
.map(InboundCartonLabelEntity::getBoxCode).toList();
}
@Transactional @Transactional
public InboundCartonLabels.Batch getOrCreate(long tenantId, long inboundId) { public InboundCartonLabels.Batch getOrCreate(long tenantId, long inboundId) {
requireIds(tenantId, inboundId); requireIds(tenantId, inboundId);
@@ -56,6 +56,7 @@ public class MeasurementBatchService {
@Resource @Resource
private FbaPricingRepository pricingRepository; private FbaPricingRepository pricingRepository;
/** 按已选算法保存测量批次及计算快照,不推进履约状态。 */
@Transactional @Transactional
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
var inbound = repository.findInbound(command.tenantId(), command.inboundId()); var inbound = repository.findInbound(command.tenantId(), command.inboundId());
@@ -68,7 +69,7 @@ public class MeasurementBatchService {
} }
validateSource(command); validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems( List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId())); command.tenantId(), command.items(), dimensionWeighting.load(command.tenantId()));
InboundMeasurementEntity measurement; InboundMeasurementEntity measurement;
if (command.measurementId() == null) { if (command.measurementId() == null) {
@@ -93,6 +94,7 @@ public class MeasurementBatchService {
return measurement; return measurement;
} }
/** 修改批次算法时,以实测及人工修正基数重算,避免重复叠加。 */
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement, void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems, List<InboundMeasurementItemEntity> existingItems,
String coefficientMode, Long chargeRuleId) { String coefficientMode, Long chargeRuleId) {
@@ -112,7 +114,7 @@ public class MeasurementBatchService {
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(), item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList()); item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
List<InboundMeasurementItemEntity> recalculated = calculateItems( List<InboundMeasurementItemEntity> recalculated = calculateItems(
command, dimensionWeighting.load(tenantId)); tenantId, command.items(), dimensionWeighting.load(tenantId));
for (int index = 0; index < existingItems.size(); index++) { for (int index = 0; index < existingItems.size(); index++) {
InboundMeasurementItemEntity target = existingItems.get(index); InboundMeasurementItemEntity target = existingItems.get(index);
InboundMeasurementItemEntity source = recalculated.get(index); InboundMeasurementItemEntity source = recalculated.get(index);
@@ -121,6 +123,7 @@ public class MeasurementBatchService {
applyTotals(measurement, existingItems); applyTotals(measurement, existingItems);
} }
/** 按保存的算法重算单行外箱修正,并更新批次汇总。 */
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement, void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems, long itemId, List<InboundMeasurementItemEntity> existingItems, long itemId,
BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerLengthCm, BigDecimal outerWidthCm,
@@ -143,14 +146,42 @@ public class MeasurementBatchService {
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(), outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
target.getRemark()))); target.getRemark())));
InboundMeasurementItemEntity recalculated = calculateItems( InboundMeasurementItemEntity recalculated = calculateItems(
command, dimensionWeighting.load(tenantId)).get(0); tenantId, command.items(), dimensionWeighting.load(tenantId)).get(0);
applyRecalculatedItem(target, recalculated, true); applyRecalculatedItem(target, recalculated, true);
applyTotals(measurement, existingItems); applyTotals(measurement, existingItems);
} }
/** 设备首次测量与复测共用批次算法,保留已有人工修正及明确选择的算法。 */
void calculateCartonDimensions(InboundMeasurementItemEntity item) {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
item.getManualOuterHeightCm(), item.getChargeRuleId(), item.getCoefficientMode(), item.getRemark());
InboundMeasurementItemEntity calculated = calculateItems(item.getTenantId(), List.of(source),
dimensionWeighting.load(item.getTenantId())).get(0);
copyCalculatedValues(item, calculated);
}
/** 保存重算后的行,保留箱码、设备证据和批次身份。 */
private void applyRecalculatedItem(InboundMeasurementItemEntity target, private void applyRecalculatedItem(InboundMeasurementItemEntity target,
InboundMeasurementItemEntity source, InboundMeasurementItemEntity source,
boolean updateManualDimensions) { boolean updateManualDimensions) {
copyCalculatedValues(target, source);
if (updateManualDimensions) {
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
}
target.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(target)) {
throw concurrentModification();
}
}
/** 统一复制尺寸、体积、材积重及算法快照,不覆盖原始测量值。 */
private void copyCalculatedValues(InboundMeasurementItemEntity target,
InboundMeasurementItemEntity source) {
target.setGirthCm(source.getGirthCm()); target.setGirthCm(source.getGirthCm());
target.setUnitVolumeCbm(source.getUnitVolumeCbm()); target.setUnitVolumeCbm(source.getUnitVolumeCbm());
target.setVolumeRawCbm(source.getVolumeRawCbm()); target.setVolumeRawCbm(source.getVolumeRawCbm());
@@ -163,15 +194,6 @@ public class MeasurementBatchService {
target.setOuterLengthCm(source.getOuterLengthCm()); target.setOuterLengthCm(source.getOuterLengthCm());
target.setOuterWidthCm(source.getOuterWidthCm()); target.setOuterWidthCm(source.getOuterWidthCm());
target.setOuterHeightCm(source.getOuterHeightCm()); target.setOuterHeightCm(source.getOuterHeightCm());
if (updateManualDimensions) {
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
}
target.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(target)) {
throw concurrentModification();
}
} }
/** /**
@@ -257,10 +279,12 @@ public class MeasurementBatchService {
measurement.setActualGoodsName(command.actualGoodsName()); measurement.setActualGoodsName(command.actualGoodsName());
} }
/** 明确的行算法优先;未选择时仅使用客户默认算法,不因预置系数而自动启用。 */
private List<InboundMeasurementItemEntity> calculateItems( private List<InboundMeasurementItemEntity> calculateItems(
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) { long tenantId, List<MeasurementBatchCommand.Item> sourceItems,
CustomerDimensionWeightingService.RuleSet dimensionRules) {
Set<String> itemNumbers = new HashSet<>(); Set<String> itemNumbers = new HashSet<>();
return command.items().stream().map(source -> { return sourceItems.stream().map(source -> {
if (!itemNumbers.add(source.itemNo())) { if (!itemNumbers.add(source.itemNo())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复"); throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
} }
@@ -279,10 +303,9 @@ public class MeasurementBatchService {
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve( CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
baseLength, baseWidth, baseHeight); baseLength, baseWidth, baseHeight);
String requestedMode = normalizeMode(source.coefficientMode()); String requestedMode = normalizeMode(source.coefficientMode());
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode) String coefficientMode = requestedMode == null ? dimensionRules.defaultCalculationMode() : requestedMode;
? requireCoefficientRule(command.tenantId(), source.chargeRuleId()) CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(coefficientMode)
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId()) : null; ? requireCoefficientRule(tenantId, source.chargeRuleId()) : null;
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
boolean useDimensionRule = "ADD".equals(coefficientMode); boolean useDimensionRule = "ADD".equals(coefficientMode);
BigDecimal addition = weighted.edgeAdditionCm() == null BigDecimal addition = weighted.edgeAdditionCm() == null
? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2)); ? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2));
@@ -374,10 +397,6 @@ public class MeasurementBatchService {
return normalized; return normalized;
} }
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId) {
return pricingRepository.findDefaultRule(tenantId, tenantId);
}
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) { private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) {
CustomerChargeRuleEntity rule = ruleId == null CustomerChargeRuleEntity rule = ruleId == null
? pricingRepository.findDefaultRule(tenantId, tenantId) ? pricingRepository.findDefaultRule(tenantId, tenantId)
@@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
location_name VARCHAR(128) NULL COMMENT '节点地点名称', location_name VARCHAR(128) NULL COMMENT '节点地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id BIGINT UNSIGNED NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name VARCHAR(128) NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -234,6 +234,7 @@ components:
locationCode: {type: string, maxLength: 32, nullable: true} locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true} locationName: {type: string, maxLength: 128, nullable: true}
sourceReference: {type: string, maxLength: 500, nullable: true} sourceReference: {type: string, maxLength: 500, nullable: true}
operatorName: {type: string, maxLength: 128, nullable: true, description: '节点操作人用户名快照;人工补录和确认出仓时记录,自动采集为空'}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
createdAt: {type: string, format: date-time} createdAt: {type: string, format: date-time}
ExceptionFact: ExceptionFact:
@@ -0,0 +1,18 @@
openapi: 3.0.3
info: { title: 原入仓有效箱唛内部查询, version: 1.0.0 }
security: [{ internalToken: [] }]
paths:
/internal/firstleg/v1/inbounds/{inboundId}/existing-carton-marks:
get:
summary: 只读查询指定客户及原入仓已有有效箱唛批次,不补建箱号
parameters:
- { in: path, name: inboundId, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { in: query, name: customerId, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { in: query, name: inboundCode, required: true, schema: { type: string } }
responses:
'200':
description: 统一响应;data为有效箱唛字符串数组,归属不匹配或无已有批次时为空;批次不完整返回业务错误
'403': { description: 内部服务凭证缺失或无效 }
components:
securitySchemes:
internalToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
@@ -147,6 +147,13 @@ paths:
description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。 description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}}
responses: {'200': {description: 成功或业务失败}} responses: {'200': {description: 成功或业务失败}}
/api/platform/v1/inbounds/{inboundId}/fees/preview:
post:
summary: 只读预览当前计费重量和体积
description: 不生成费用版本,不修改测量、费用、履约状态或财务记录;与正式计价共用价格匹配及柜级阶梯规则。FBA价格未命中时返回 priceMatched=false、计费量和空单价,供人工单价预览使用;其他错误明确返回。需要 firstleg:inbound:confirm-send-fee 或 firstleg:fba-pricing:calculate 权限。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/calculate: /api/platform/v1/inbounds/{inboundId}/fees/calculate:
post: post:
summary: 按审核通过测量快照试算 FBA 费用 summary: 按审核通过测量快照试算 FBA 费用
@@ -156,8 +163,8 @@ paths:
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/manual-quote: /api/platform/v1/inbounds/{inboundId}/fees/manual-quote:
post: post:
summary: FBA价格未命中时人工确定头程单价 summary: 人工确定FBA未命中报价或私人地址头程单价
description: 仅FBA且价格查询返回未命中时可用;按客户计费方式与选定测量重量、体积计算计费量,附加费为零。生成正式头程费用快照,后续复用确认与发送;需要 firstleg:inbound:confirm-send-fee 权限。 description: FBA须价格查询返回未命中;私人地址PRIVATE按人工单价报价,预览返回计费量且priceMatched=false。按客户计费方式与选定测量重量、体积计算计费量,附加费为零。生成CALCULATED正式头程费用快照并保留WAIT_SPLIT,后续复用版本、确认、发送与幂等流程,预装确认继续校验初始费用已完成;需要 firstleg:inbound:confirm-send-fee 权限。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ManualQuoteRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ManualQuoteRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
@@ -296,6 +303,32 @@ components:
updatedAt: {type: string, format: date-time, nullable: true} updatedAt: {type: string, format: date-time, nullable: true}
deleted: {type: boolean, nullable: true} deleted: {type: boolean, nullable: true}
version: {type: integer, nullable: true} version: {type: integer, nullable: true}
FeePreviewApiResponse:
type: object
required: [code, message, data]
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/FeePreviewResponse'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time, nullable: true}
FeePreviewResponse:
type: object
required: [actualWeightKg, actualVolumeCbm, chargeableQuantity, chargeUnit, conversionFactor, roundingScale, roundingMode, unitPrice, additionalAmount, totalAmount, currency, priceMatched]
properties:
actualWeightKg: {type: number}
actualVolumeCbm: {type: number}
chargeableQuantity: {type: number}
chargeUnit: {type: string, enum: [KG, CBM]}
conversionFactor: {type: number}
roundingScale: {type: integer}
roundingMode: {type: string}
unitPrice: {type: number, nullable: true}
additionalAmount: {type: number}
totalAmount: {type: number, nullable: true, description: 按本次人工单价或参考单价计算并取整后的费用合计;两种单价都缺失时为空}
currency: {type: string}
priceMatched: {type: boolean}
FeeSnapshotApiResponse: FeeSnapshotApiResponse:
type: object type: object
required: [code, message, data] required: [code, message, data]
@@ -503,6 +536,15 @@ components:
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0} selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0}
unitPrice: {type: number, exclusiveMinimum: true, minimum: 0} unitPrice: {type: number, exclusiveMinimum: true, minimum: 0}
reason: {type: string, minLength: 2, maxLength: 500} reason: {type: string, minLength: 2, maxLength: 500}
FeePreviewRequest:
type: object
properties:
pricingAt: {type: string, format: date-time, nullable: true}
selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
weightSource: {type: string, maxLength: 32, nullable: true}
volumeSource: {type: string, maxLength: 32, nullable: true}
selectedUnitPrice: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 本次人工单价;为空时使用当前参考单价,仅用于只读金额预览}
CalculateRequest: CalculateRequest:
type: object type: object
properties: properties:
@@ -2245,7 +2245,7 @@ components:
outerWidthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱宽度修正;保存后参与联动计算} outerWidthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱宽度修正;保存后参与联动计算}
outerHeightCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱高度修正;保存后参与联动计算} outerHeightCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱高度修正;保存后参与联动计算}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true, description: 明确选择的行算法优先;未指定时仅使用客户默认算法,ACTUAL 不调整修正后的实测尺寸,系数配置存在不代表启用 MULTIPLY}
remark: {type: string, maxLength: 500, nullable: true} remark: {type: string, maxLength: 500, nullable: true}
InboundMeasurementDetailEnvelope: InboundMeasurementDetailEnvelope:
type: object type: object
@@ -19,6 +19,7 @@ class FbaPricingContractTest {
@Test @Test
@DisplayName("确认和发送费用兼容新的组合权限与原有权限") @DisplayName("确认和发送费用兼容新的组合权限与原有权限")
void allowsCombinedConfirmSendPermissionAndLegacyPermissions() { void allowsCombinedConfirmSendPermissionAndLegacyPermissions() {
assertPricingAuthority("preview", "hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')");
assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:fba-pricing:confirm')"); + " 'firstleg:fba-pricing:confirm')");
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
@@ -58,6 +59,8 @@ class FbaPricingContractTest {
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi) assertThat(openapi)
.contains("/api/platform/v1/fba-pricing/customer-rules/versions:") .contains("/api/platform/v1/fba-pricing/customer-rules/versions:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/preview:")
.contains("不生成费用版本")
.contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:") .contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/versions:") .contains("/api/platform/v1/fba-pricing/carrier-rates/versions:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:")
@@ -28,6 +28,14 @@ class ContainerCustomsMapperContractTest {
assertThat(currentRead.getCache()).isNull(); assertThat(currentRead.getCache()).isNull();
} }
@Test void containerQueriesExplicitlyMapEveryRecordComponentInOrder() throws Exception {
assertRecordProjection("container", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("containerById", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("containerByBooking", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("lockContainer", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("lockContainerById", ContainerCustomsMapper.ContainerRow.class, long.class);
}
@Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception { @Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception {
String value=sql("completedPlanForOrder",long.class,long.class); String value=sql("completedPlanForOrder",long.class,long.class);
assertThat(value) assertThat(value)
@@ -8,6 +8,16 @@ import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryOpenApiContractTest { class CarrierScheduleQueryOpenApiContractTest {
/** 两个节点读取入口使用相同的操作用户名响应契约。 */
@Test
void nodeFactsExposeTheRecordedOperatorUsername() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
String node = yaml.substring(yaml.indexOf(" NodeFact:"), yaml.indexOf(" ExceptionFact:"));
assertThat(node).contains("operatorName: {type: string, maxLength: 128, nullable: true")
.contains("节点操作人用户名快照");
}
@Test @Test
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception { void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml") String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
@@ -16,6 +16,23 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySqlContractTest { class CarrierScheduleQuerySqlContractTest {
/** 模块与部署基线共同保存节点署名,Mapper 同次写入两项快照。 */
@Test
void nodeFactsPersistOperatorSnapshotInBothBaselines() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
for (String source : List.of(moduleSql(), unified)) {
String lower = source.toLowerCase(java.util.Locale.ROOT);
int start = lower.indexOf("fl_carrier_schedule_node_fact (");
String table = lower.substring(start, lower.indexOf("engine=innodb", start));
assertThat(table).contains("operator_id bigint unsigned", "operator_name varchar(128)");
}
String insert = String.join("\n", com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper.class
.getMethod("insertStrict", com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)
.getAnnotation(org.apache.ibatis.annotations.Insert.class).value());
assertThat(insert).contains("operator_id,operator_name", "#{value.operatorId},#{value.operatorName}");
}
@Test @Test
void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception { void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception {
JdbcDataSource database = new JdbcDataSource(); JdbcDataSource database = new JdbcDataSource();
@@ -133,14 +133,21 @@ class CarrierScheduleExceptionPersistenceTest {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8);
} }
/** 真实 Mapper 保存并返回操作用户名,账号更名后重放不改写原署名。 */
@Test @Test
void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() { void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() {
var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
"ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles", "ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles",
"synthetic container manual fact"); "synthetic container manual fact");
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body); var detail = service.addContainerManualNode(3,10001,"platform_admin","original-body-key",body);
assertThat(detail.nodes().stream().filter(node -> "MANUAL".equals(node.providerCode())).toList())
.singleElement().satisfies(node -> assertThat(node.operatorName()).isEqualTo("platform_admin"));
assertThat(jdbc.queryForObject("SELECT operator_id FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", Long.class))
.isEqualTo(10001L);
assertThat(jdbc.queryForObject("SELECT operator_name FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", String.class))
.isEqualTo("platform_admin");
var saved = allSnapshot(); var saved = allSnapshot();
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body); service.addContainerManualNode(3,10001,"renamed_operator","original-body-key",body);
assertThat(allSnapshot()).isEqualTo(saved); assertThat(allSnapshot()).isEqualTo(saved);
assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key", assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key",
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
@@ -183,6 +183,7 @@ class CarrierScheduleQueryServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移"); .isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
} }
/** 出仓节点记录确认人的账号,供两个货柜详情展示。 */
@Test @Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
@@ -194,7 +195,7 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"); CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "platform_admin");
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO"); assertThat(result.getCarrierCode()).isEqualTo("COSCO");
@@ -204,23 +205,25 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(result.getCreatedBy()).isZero(); assertThat(result.getCreatedBy()).isEqualTo(7L);
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM"); assertThat(result.getCreatedByName()).isEqualTo("platform_admin");
assertThat(result.getUpdatedBy()).isZero(); assertThat(result.getUpdatedBy()).isEqualTo(7L);
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM"); assertThat(result.getUpdatedByName()).isEqualTo("platform_admin");
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox).publish(eq(result), argThat(fact -> verify(nodeOutbox).publish(eq(result), argThat(fact ->
"FIRSTLEG".equals(fact.getProviderCode()) "FIRSTLEG".equals(fact.getProviderCode())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId()) && "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode()) && "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode()) && "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId())
&& "platform_admin".equals(fact.getOperatorName())
&& fact.getActualAt() != null), eq("ZCSU7881970")); && fact.getActualAt() != null), eq("ZCSU7881970"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction()) "AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null && history.getFromVersion() == null
&& Integer.valueOf(0).equals(history.getToVersion()) && Integer.valueOf(0).equals(history.getToVersion())
&& Long.valueOf(0L).equals(history.getOperatorId()) && Long.valueOf(7L).equals(history.getOperatorId())
&& "SYSTEM".equals(history.getOperatorName()) && "platform_admin".equals(history.getOperatorName())
&& "国内确认出仓自动创建".equals(history.getReason()))); && "国内确认出仓自动创建".equals(history.getReason())));
} }
@@ -360,6 +363,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
} }
/** 自动采集推进原节点,但不冒用平台操作账号。 */
@Test @Test
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0); CarrierScheduleQueryEntity task = task(0, 0);
@@ -377,6 +381,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT"); assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
assertThat(result.nodes()).isEmpty(); assertThat(result.nodes()).isEmpty();
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
fact.getOperatorId() == null && fact.getOperatorName() == null));
} }
@Test @Test
@@ -448,6 +454,7 @@ class CarrierScheduleQueryServiceTest {
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970")); verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
} }
/** 人工实际节点保留操作账号及既有还柜幂等行为。 */
@Test @Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3); CarrierScheduleQueryEntity task = task(0, 3);
@@ -462,6 +469,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("STOPPED"); assertThat(result.query().query().status()).isEqualTo("STOPPED");
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED"); assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
} }
@@ -488,6 +497,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository).updateState(before, 3, 7L, "admin"); verify(repository).updateState(before, 3, 7L, "admin");
} }
/** 柜级补录沿用当前账号,并保持已有跟踪任务和事件投递。 */
@Test @Test
void containerManualNodeJoinsAndAdvancesExistingCarrierTask() { void containerManualNodeJoinsAndAdvancesExistingCarrierTask() {
CarrierScheduleQueryEntity task = task(0, 8); CarrierScheduleQueryEntity task = task(0, 8);
@@ -509,7 +519,8 @@ class CarrierScheduleQueryServiceTest {
"PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup")); "PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup"));
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId()) verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId())
&& fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode()))); && fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
verify(repository).updateState(task, 8, 7L, "admin"); verify(repository).updateState(task, 8, 7L, "admin");
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId()) "MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId())
@@ -0,0 +1,76 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 使用真实 MyBatis 更新语句验证清空外箱修正,不以 Mock 掩盖 NULL 被忽略的问题。 */
class InboundMeasurementItemPersistenceTest {
private SqlSession session;
private JdbcTemplate sql;
private InboundMeasurementRepository repository;
/** 每项用例使用隔离内存表,仅保留本轮更新涉及的列。 */
@BeforeEach
void setUp() {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:measurement-clear-" + UUID.randomUUID() + ";MODE=MySQL");
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(InboundMeasurementItemMapper.class);
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
session.getConnection();
sql = new JdbcTemplate(ds);
sql.execute("CREATE TABLE fl_inbound_measurement_item(id BIGINT PRIMARY KEY, tenant_id BIGINT, "
+ "manual_outer_length_cm DECIMAL(12,3), manual_outer_width_cm DECIMAL(12,3), "
+ "manual_outer_height_cm DECIMAL(12,3), charge_rule_id BIGINT)");
sql.update("INSERT INTO fl_inbound_measurement_item VALUES (88, 10001, 63, 53, 53, 77)");
repository = new InboundMeasurementRepository();
ReflectionTestUtils.setField(repository, "items", session.getMapper(InboundMeasurementItemMapper.class));
}
/** 关闭隔离数据库的最后一个会话,释放本项测试资源。 */
@AfterEach
void tearDown() {
if (session != null) session.close();
}
/** 单边清空与全部清空都须真正持久化,并可解除已取消的系数规则关联。 */
@ParameterizedTest
@CsvSource({"false,true,true", "true,false,true", "true,true,false", "false,false,false"})
void persistsClearedWarehouseDimensions(boolean keepLength, boolean keepWidth, boolean keepHeight) {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(88L);
item.setTenantId(10001L);
item.setManualOuterLengthCm(keepLength ? new BigDecimal("63") : null);
item.setManualOuterWidthCm(keepWidth ? new BigDecimal("53") : null);
item.setManualOuterHeightCm(keepHeight ? new BigDecimal("53") : null);
item.setChargeRuleId(null);
assertThat(repository.updateItem(item)).isTrue();
var stored = sql.queryForMap("SELECT * FROM fl_inbound_measurement_item WHERE id=88");
assertThat(stored.get("MANUAL_OUTER_LENGTH_CM")).isEqualTo(keepLength ? new BigDecimal("63.000") : null);
assertThat(stored.get("MANUAL_OUTER_WIDTH_CM")).isEqualTo(keepWidth ? new BigDecimal("53.000") : null);
assertThat(stored.get("MANUAL_OUTER_HEIGHT_CM")).isEqualTo(keepHeight ? new BigDecimal("53.000") : null);
assertThat(stored.get("CHARGE_RULE_ID")).isNull();
item.setTenantId(20002L);
assertThat(repository.updateItem(item)).isFalse();
}
}
@@ -7,12 +7,14 @@ import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult; import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType; import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -46,13 +48,18 @@ class CartonMeasurementServiceTest {
private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class); private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class);
private final CustomerDimensionWeightingService dimensionWeighting = private final CustomerDimensionWeightingService dimensionWeighting =
new CustomerDimensionWeightingService(dimensionConfigs); new CustomerDimensionWeightingService(dimensionConfigs);
private final MeasurementBatchService measurementBatchService = new MeasurementBatchService();
private final FbaPricingRepository pricingRepository = mock(FbaPricingRepository.class);
private CartonMeasurementService service; private CartonMeasurementService service;
private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>(); private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>();
@BeforeEach @BeforeEach
/** 构造真实共享尺寸计算入口,设备回归不以模拟计算结果代替业务算法。 */
void setUp() { void setUp() {
ReflectionTestUtils.setField(measurementBatchService, "dimensionWeighting", dimensionWeighting);
ReflectionTestUtils.setField(measurementBatchService, "pricingRepository", pricingRepository);
service = new CartonMeasurementService(repository, resolverProvider, images, transactions, service = new CartonMeasurementService(repository, resolverProvider, images, transactions,
dimensionWeighting); measurementBatchService);
ReflectionTestUtils.setField(service, "internalToken", "internal-token"); ReflectionTestUtils.setField(service, "internalToken", "internal-token");
when(resolverProvider.getIfAvailable()).thenReturn(resolver); when(resolverProvider.getIfAvailable()).thenReturn(resolver);
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1")) when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
@@ -164,7 +171,9 @@ class CartonMeasurementServiceTest {
} }
@Test @Test
/** 超出四档尺寸且未启用调整规则时,仍按设备原尺寸计算。 */
void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesActualDimensions() { void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesActualDimensions() {
useDefaultMode("ACTUAL");
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
@@ -187,7 +196,7 @@ class CartonMeasurementServiceTest {
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("60.100"); assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("60.100");
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("50.100"); assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("50.100");
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("50.100"); assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("50.100");
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.151"); assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.1509");
assertThat(insertedItem.get().getOverLimitWarning()).isNull(); assertThat(insertedItem.get().getOverLimitWarning()).isNull();
verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE)); verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE));
} }
@@ -347,6 +356,61 @@ class CartonMeasurementServiceTest {
verify(repository).updateInbound(inbound); verify(repository).updateInbound(inbound);
} }
@Test
/** 预置系数记录存在但未启用时,设备首次测量不加边也不乘系数。 */
void firstScanUsesActualDimensionsWhenCoefficientIsNotEnabled() {
useDefaultMode("ACTUAL");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setLengthFactor(new BigDecimal("1.25"));
when(pricingRepository.findDefaultRule(10001L, 10001L)).thenReturn(rule);
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
when(repository.updateMeasurement(measurement)).thenReturn(true);
service.upsert(10001L, 90001L, acceptanceCommand());
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("59.500");
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("41.200");
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("31.300");
assertThat(insertedItem.get().getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.0768");
verify(pricingRepository, never()).findDefaultRule(10001L, 10001L);
}
@Test
/** 客户明确启用系数后,设备首次录入与人工批次使用相同系数算法。 */
void firstScanAppliesExplicitlyEnabledMultiplyDefault() {
useDefaultMode("MULTIPLY");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(77L);
rule.setTenantId(10001L);
rule.setStatus("ACTIVE");
rule.setRuleVersion(2);
rule.setLengthFactor(new BigDecimal("1.25"));
when(pricingRepository.findDefaultRule(10001L, 10001L)).thenReturn(rule);
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
when(repository.updateMeasurement(measurement)).thenReturn(true);
service.upsert(10001L, 90001L, acceptanceCommand());
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("74.375");
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("51.500");
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("39.125");
assertThat(insertedItem.get().getCoefficientMode()).isEqualTo("MULTIPLY");
assertThat(insertedItem.get().getChargeRuleId()).isEqualTo(77L);
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.1499");
}
/** 设置客户默认算法,与是否保存系数规则分开。 */
private void useDefaultMode(String mode) {
List<CustomerDimensionConfigEntity> rules = dimensionRules(10001L);
rules.forEach(rule -> rule.setDefaultCalculationMode(mode));
when(dimensionConfigs.findAll(10001L)).thenReturn(rules);
}
private CartonMeasurementCommand command() { private CartonMeasurementCommand command() {
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"), return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"),
new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("40.200"), new BigDecimal("30.300"),
@@ -388,6 +452,7 @@ class CartonMeasurementServiceTest {
return item; return item;
} }
/** 既有加边用例显式启用 ADD,避免依赖配置存在即生效的旧行为。 */
private List<CustomerDimensionConfigEntity> dimensionRules(long tenantId) { private List<CustomerDimensionConfigEntity> dimensionRules(long tenantId) {
return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> { return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> {
CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity(); CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity();
@@ -395,6 +460,7 @@ class CartonMeasurementServiceTest {
rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm()); rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm());
rule.setInnerHeightCm(type.defaultHeightCm()); rule.setInnerHeightCm(type.defaultHeightCm());
rule.setEdgeAdditionCm(new BigDecimal("2.000")); rule.setEdgeAdditionCm(new BigDecimal("2.000"));
rule.setDefaultCalculationMode("ADD");
return rule; return rule;
}).toList(); }).toList();
} }
@@ -39,15 +39,9 @@ class CustomerDimensionWeightingServiceTest {
} }
@Test @Test
void deviceMeasurementsUseActualDimensionsAndAddEachSideWhenRuleMatches() { /** 未勾选默认调整算法时,四档尺寸配置不能隐式启用加边。 */
var weighted = service.load(10001L).resolveDeviceMeasurement( void defaultCalculationModeRemainsActualWhenNoAdjustmentWasSelected() {
new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10")); assertThat(service.load(10001L).defaultCalculationMode()).isEqualTo("ACTUAL");
assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL);
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("14.000");
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("14.000");
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("14.000");
assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3");
} }
@Test @Test
@@ -81,16 +75,15 @@ class CustomerDimensionWeightingServiceTest {
} }
@Test @Test
void deviceDimensionsOutsideConfiguredRulesRemainUnchanged() { /** 默认算法必须在四档配置中一致,防止各录入入口取得不同计算方式。 */
var weighted = service.load(10001L).resolveDeviceMeasurement( void inconsistentCustomerDefaultModesAreRejected() {
new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50")); List<CustomerDimensionConfigEntity> rules = defaultRules(10001L);
rules.get(0).setDefaultCalculationMode("MULTIPLY");
when(repository.findAll(10001L)).thenReturn(rules);
assertThat(weighted.boxType()).isNull(); assertThatThrownBy(() -> service.load(10001L))
assertThat(weighted.edgeAdditionCm()).isNull(); .isInstanceOf(BizException.class)
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("60.001"); .hasMessageContaining("默认箱规计算方式配置不一致");
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("50.000");
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("50.000");
assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
} }
@Test @Test
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity;
@@ -70,6 +71,87 @@ class FbaPricingServiceTest {
@MockBean private ContainerTierAmountAllocator containerTierAllocator; @MockBean private ContainerTierAmountAllocator containerTierAllocator;
@Resource private FbaPricingService service; @Resource private FbaPricingService service;
@Test
/** 预览只按当前选择值估价,不复用旧费用,也不产生任何计费写入。 */
void previewsSelectedOuterVolumeWithoutWritingFeeState() {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("570.500", "3.6894"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(901L);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setOuterLengthCm(new BigDecimal("63"));
item.setOuterWidthCm(new BigDecimal("53"));
item.setOuterHeightCm(new BigDecimal("53"));
item.setCalculationRuleVersion("WAREHOUSE_OUTER_OVERRIDE_V1");
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 901L)).thenReturn(List.of(item));
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "650.298000", "13.6", "8844.05", "0"));
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
new BigDecimal("570.500"), new BigDecimal("3.8940"), "ACTUAL", "OUTER", null));
assertThat(preview.actualVolumeCbm()).isEqualByComparingTo("3.8940");
assertThat(preview.chargeableQuantity()).isEqualByComparingTo("650.298000");
assertThat(preview.totalAmount()).isEqualByComparingTo("8844.05");
assertThat(preview.priceMatched()).isTrue();
verify(priceBookEstimator).estimate(eq(TENANT), argThat(request ->
request.actualWeightKg().compareTo(new BigDecimal("570.5")) == 0
&& request.actualVolumeCbm().compareTo(new BigDecimal("3.894")) == 0));
verify(repository, never()).insertFee(any());
verify(repository, never()).insertIdempotency(any());
verify(repository, never()).nextFeeVersion(anyLong(), anyLong());
verify(repository, never()).markInboundCalculated(anyLong(), anyLong(), any(), any(), any(), any(), any());
org.mockito.Mockito.verifyNoInteractions(financeOutbox, feeDetails);
}
@Test
/** FBA 未命中价格时仍返回服务端计费量,供人工单价预览使用。 */
void previewsManualQuantityWhenPriceIsMissing() {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("570.500", "3.6894"));
when(priceBookEstimator.estimate(anyLong(), any()))
.thenThrow(new BizException(ResultCode.NOT_FOUND, "价格未命中"));
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
new BigDecimal("570.5"), new BigDecimal("3.894"), null, null, null));
assertThat(preview.priceMatched()).isFalse();
assertThat(preview.unitPrice()).isNull();
assertThat(preview.chargeableQuantity()).isEqualByComparingTo("650.298000");
assertThat(preview.additionalAmount()).isZero();
assertThat(preview.totalAmount()).isNull();
verify(repository, never()).insertFee(any());
}
@Test
/** 外箱快照缺失时拒绝预览,不把当前规则的前端推导结果视作已保存事实。 */
void rejectsOuterPreviewWithoutSavedMeasurementSnapshot() {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("100", "1"));
assertThatThrownBy(() -> service.preview(TENANT, INBOUND,
new FeePreviewRequest(NOW, new BigDecimal("100"), BigDecimal.ONE, "ACTUAL", "OUTER", null)))
.isInstanceOf(BizException.class).hasMessageContaining("测量数据不存在");
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox, feeDetails);
}
@Test
/** 预览不能读取其他客户的入库单。 */
void rejectsCrossCustomerPreview() {
assertThatThrownBy(() -> service.preview(10002L, INBOUND, new FeePreviewRequest(NOW, null, null, null, null, null)))
.isInstanceOf(BizException.class).hasMessageContaining("入库单不存在");
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox, feeDetails);
}
@Test
/** 人工单价的金额也由服务端精确取整,不在页面重复实现舍入。 */
void previewsManualUnitPriceUsingExactMoneyRounding() {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("1", "0.001"));
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(estimate("KG", "1", "2", "2", "0"));
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
BigDecimal.ONE, new BigDecimal("0.001"), null, null, new BigDecimal("1.005")));
assertThat(preview.unitPrice()).isEqualByComparingTo("2");
assertThat(preview.totalAmount()).isEqualByComparingTo("1.01");
verify(repository, never()).insertFee(any());
}
@Test @Test
@DisplayName("客户B不能读取客户A的费用快照") @DisplayName("客户B不能读取客户A的费用快照")
void rejectsCrossCustomerFeeLookup() { void rejectsCrossCustomerFeeLookup() {
@@ -401,6 +483,83 @@ class FbaPricingServiceTest {
assertThat(feeItem.getValue().getRemark()).isEqualTo("FBA价格未维护"); assertThat(feeItem.getValue().getRemark()).isEqualTo("FBA价格未维护");
} }
@Test
void previewsPrivateAddressManualQuantityWithoutLookingUpWarehouseRates() {
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
inbound.setDestinationCode(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
var result = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
new BigDecimal("62.500"), new BigDecimal("0.3840"), null, null, new BigDecimal("12.2")));
assertThat(result.priceMatched()).isFalse();
assertThat(result.chargeableQuantity()).isEqualByComparingTo("64.128000");
assertThat(result.totalAmount()).isEqualByComparingTo("782.36");
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, feeDetails, financeOutbox);
verify(repository, never()).insertFee(any());
}
@Test
void createsPrivateAddressManualQuoteAndPreservesWaitSplit() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
inbound.setDestinationCode(null);
inbound.setProcessStatus("WAIT_SPLIT");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity()));
var request = new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"),
new BigDecimal("12.2"), "私人地址人工报价");
var result = service.manualQuote(TENANT, OPERATOR, INBOUND, request, "private-manual");
assertThat(result.rateCode()).isEqualTo("MANUAL_QUOTE");
assertThat(result.feeStatus()).isEqualTo("CALCULATED");
assertThat(result.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.totalAmount()).isEqualByComparingTo("782.36");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getDestinationType()).isEqualTo("PRIVATE");
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
assertThat(fee.getValue().getAdditionalAmount()).isEqualByComparingTo("0");
ArgumentCaptor<InboundFeeItemEntity> item = ArgumentCaptor.forClass(InboundFeeItemEntity.class);
verify(repository).insertItem(item.capture());
assertThat(item.getValue().getFeeName()).isEqualTo("头程基础运费");
assertThat(item.getValue().getAutomaticFlag()).isFalse();
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox);
ArgumentCaptor<InboundPricingIdempotencyEntity> command =
ArgumentCaptor.forClass(InboundPricingIdempotencyEntity.class);
verify(repository).insertIdempotency(command.capture());
command.getValue().setStatus("COMPLETED");
command.getValue().setResultResourceId(result.feeId());
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_MANUAL_QUOTE", "private-manual"))
.thenReturn(command.getValue());
when(repository.findFee(TENANT, result.feeId())).thenReturn(fee.getValue());
when(repository.findBaseItem(TENANT, result.feeId())).thenReturn(item.getValue());
var replay = service.manualQuote(TENANT, OPERATOR, INBOUND, request, "private-manual");
assertThat(replay.feeId()).isEqualTo(result.feeId());
verify(repository, times(1)).insertFee(any());
verify(repository, times(1)).insertIdempotency(any());
}
@Test
void rejectsAutomaticPricingForPrivateAddress() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "private-auto"))
.isInstanceOf(BizException.class).hasMessageContaining("私人地址请使用人工头程报价");
verify(repository, never()).insertFee(any());
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator);
}
@Test @Test
@DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价") @DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价")
void rejectsManualQuoteWhenRateExists() { void rejectsManualQuoteWhenRateExists() {
@@ -30,6 +30,18 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class InboundCartonLabelServiceTest { class InboundCartonLabelServiceTest {
/** 海外溯源必须使用已有批次,不得因空记录补建箱号。 */
@Test void existingMarksAreReadOnlyAndCheckInboundCode() {
when(repository.findActiveByInbound(1L, 2L)).thenReturn(List.of());
assertThat(service.existingCartonMarks(1L, 2L, "IN")).isEmpty();
var label = new InboundCartonLabelEntity();
label.setTotalCartonCount(1); label.setCartonIndex(1); label.setLabelVersion(1);
label.setBatchStatus("ACTIVE"); label.setInboundCode("IN"); label.setBoxCode("IN#1-1");
when(repository.findActiveByInbound(1L, 2L)).thenReturn(List.of(label));
assertThat(service.existingCartonMarks(1L, 2L, "IN")).containsExactly("IN#1-1");
assertThat(service.existingCartonMarks(1L, 2L, "OTHER")).isEmpty();
org.mockito.Mockito.verifyNoInteractions(orderCargoClient, inboundOrderCreationService, labelSkus);
}
@Mock private InboundCartonLabelRepository repository; @Mock private InboundCartonLabelRepository repository;
@Mock private OrderCargoClient orderCargoClient; @Mock private OrderCargoClient orderCargoClient;
@Mock private PrintableOrderInboundClient printableOrderInboundClient; @Mock private PrintableOrderInboundClient printableOrderInboundClient;
@@ -52,6 +52,7 @@ class MeasurementBatchServiceTest {
} }
@Test @Test
/** 未选择调整算法时,第三方与人工批次均保存实际外箱快照。 */
void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() { void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() {
MeasurementBatchCommand command = new MeasurementBatchCommand( MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001", 1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001",
@@ -73,10 +74,10 @@ class MeasurementBatchServiceTest {
assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000"); assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000");
assertThat(items.getValue().get(0).getCalculationRuleVersion()) assertThat(items.getValue().get(0).getCalculationRuleVersion())
.isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1"); .isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1");
assertThat(items.getValue().get(0).getCoefficientMode()).isNull(); assertThat(items.getValue().get(0).getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(items.getValue().get(0).getChargeRuleId()).isNull(); assertThat(items.getValue().get(0).getChargeRuleId()).isNull();
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000"); assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000");
verify(pricingRepository).findDefaultRule(1L, 1L); verify(pricingRepository, never()).findDefaultRule(1L, 1L);
verify(repository, never()).updateInbound(any()); verify(repository, never()).updateInbound(any());
} }
@@ -173,8 +174,78 @@ class MeasurementBatchServiceTest {
verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
} }
/** 全部清空人工修正后,计算与批次汇总须恢复设备实测尺寸。 */
@Test @Test
void clearsWarehouseOverridesAndRecalculatesFromDeviceDimensions() {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(100L);
measurement.setTenantId(1L);
measurement.setInboundId(10L);
measurement.setVersion(3);
measurement.setCreatedBy(9L);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(200L);
item.setTenantId(1L);
item.setItemNo("B1");
item.setPackageCount(22);
item.setLengthCm(new BigDecimal("62"));
item.setWidthCm(new BigDecimal("52"));
item.setHeightCm(new BigDecimal("52"));
item.setManualOuterLengthCm(new BigDecimal("63"));
item.setManualOuterWidthCm(new BigDecimal("53"));
item.setManualOuterHeightCm(new BigDecimal("53"));
item.setCoefficientMode("ACTUAL");
when(repository.updateItem(item)).thenReturn(true);
service.recalculateOuterDimensions(1L, measurement, List.of(item), 200L, null, null, null);
assertThat(item.getManualOuterLengthCm()).isNull();
assertThat(item.getManualOuterWidthCm()).isNull();
assertThat(item.getManualOuterHeightCm()).isNull();
assertThat(item.getOuterLengthCm()).isEqualByComparingTo("62.000");
assertThat(item.getOuterWidthCm()).isEqualByComparingTo("52.000");
assertThat(item.getOuterHeightCm()).isEqualByComparingTo("52.000");
assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("3.6894");
verify(repository).updateItem(item);
}
@Test
/** 客户未启用算法时,预置系数不能放大仓库修正的实际外箱体积。 */
void savedCoefficientDoesNotOverrideActualDefaultMode() {
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(77L);
rule.setTenantId(1L);
rule.setCustomerId(1L);
rule.setStatus("ACTIVE");
rule.setDefaultRule(true);
rule.setLengthFactor(new BigDecimal("1.25"));
when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule);
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 22, new BigDecimal("62"), new BigDecimal("52"),
new BigDecimal("52"), 500L, null, new BigDecimal("63"), new BigDecimal("53"),
new BigDecimal("53"), null, null, "仓库修正");
service.saveBatch(new MeasurementBatchCommand(
1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)));
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
InboundMeasurementItemEntity calculated = items.getValue().get(0);
assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("63.000");
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("53.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("53.000");
assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("3.8940");
assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(calculated.getChargeRuleId()).isNull();
verify(pricingRepository, never()).findDefaultRule(1L, 1L);
}
@Test
/** 只有客户明确启用系数算法,才自动选取当前默认系数配置。 */
void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() { void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() {
useDefaultMode("MULTIPLY");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(77L); rule.setId(77L);
rule.setTenantId(1L); rule.setTenantId(1L);
@@ -212,7 +283,9 @@ class MeasurementBatchServiceTest {
} }
@Test @Test
/** 系数模式按当前尺寸匹配档位,同一系数作用于三个维度。 */
void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() { void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() {
useDefaultMode("MULTIPLY");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(78L); rule.setId(78L);
rule.setTenantId(1L); rule.setTenantId(1L);
@@ -251,6 +324,7 @@ class MeasurementBatchServiceTest {
} }
@Test @Test
/** 无默认系数且未选择调整算法时,显式保存 ACTUAL 计算方式。 */
void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() { void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
@@ -269,7 +343,7 @@ class MeasurementBatchServiceTest {
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000"); assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000"); assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000");
assertThat(calculated.getChargeRuleId()).isNull(); assertThat(calculated.getChargeRuleId()).isNull();
assertThat(calculated.getCoefficientMode()).isNull(); assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1"); assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
} }
@@ -530,6 +604,58 @@ class MeasurementBatchServiceTest {
assertThat(saved.getDeviceMeasuredAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); assertThat(saved.getDeviceMeasuredAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
} }
@Test
/** 行明确选择实测算法后,即使客户默认系数已启用,也不能被覆盖。 */
void explicitActualModeOverridesCustomerMultiplyDefault() {
useDefaultMode("MULTIPLY");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setTenantId(1L);
item.setItemNo("B1");
item.setPackageCount(22);
item.setLengthCm(new BigDecimal("62"));
item.setWidthCm(new BigDecimal("52"));
item.setHeightCm(new BigDecimal("52"));
item.setManualOuterLengthCm(new BigDecimal("63"));
item.setManualOuterWidthCm(new BigDecimal("53"));
item.setManualOuterHeightCm(new BigDecimal("53"));
item.setCoefficientMode("ACTUAL");
service.calculateCartonDimensions(item);
assertThat(item.getTotalVolumeCbm()).isEqualByComparingTo("3.8940");
assertThat(item.getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(item.getChargeRuleId()).isNull();
verify(pricingRepository, never()).findDefaultRule(anyLong(), anyLong());
}
@Test
/** 客户明确启用加边规则后,未指定行算法的设备及人工录入均采用该默认方式。 */
void appliesCustomerAddDefaultWhenRowModeIsNotSelected() {
useDefaultMode("ADD");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setTenantId(1L);
item.setItemNo("B1");
item.setPackageCount(1);
item.setLengthCm(new BigDecimal("30"));
item.setWidthCm(new BigDecimal("20"));
item.setHeightCm(new BigDecimal("20"));
service.calculateCartonDimensions(item);
assertThat(item.getOuterLengthCm()).isEqualByComparingTo("34.000");
assertThat(item.getOuterWidthCm()).isEqualByComparingTo("24.000");
assertThat(item.getOuterHeightCm()).isEqualByComparingTo("24.000");
assertThat(item.getCoefficientMode()).isEqualTo("ADD");
verify(pricingRepository, never()).findDefaultRule(anyLong(), anyLong());
}
/** 为用例设置客户真正启用的算法,独立于系数配置是否存在。 */
private void useDefaultMode(String mode) {
List<CustomerDimensionConfigEntity> rules = dimensionRules(1L);
rules.forEach(rule -> rule.setDefaultCalculationMode(mode));
when(dimensionConfigs.findAll(1L)).thenReturn(rules);
}
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item( return new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
@@ -0,0 +1,13 @@
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
@@ -30,8 +30,8 @@ DROP PROCEDURE check_overseas_fee_permission_identity;
START TRANSACTION; START TRANSACTION;
INSERT INTO iam_permission INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,0) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=0,status='ACTIVE',deleted=0; ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=5105000,sort_no=60,status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,5117200,CURRENT_TIMESTAMP(3) FROM iam_role r SELECT 0,r.id,5117200,CURRENT_TIMESTAMP(3) FROM iam_role r
+6 -5
View File
@@ -17,7 +17,7 @@
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。 拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有业务按无实际拼车货物、外配任务按已实际接入需求池货物分类;车辆归属仍读取装车快照,完整判定唯一见外配业务契约。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 为装车后运营视图;判定与权限分别见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`;OpenAPI 为各自 `fleet-*-business-v1.yaml`,部署/验收只查环境主契约。
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
@@ -68,18 +68,19 @@
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java` | | 海外业务费用来源、费用明细审核与重试 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`;目录入口 `financefee/controller/PlatformOverseasFeeCatalogController.java`,细则查详细索引同主题 |
| 自动仓租、仓租库存与出库资金预校验 | `storagerent/controller/PlatformStorageRentController.java`、`storagerent/controller/PdaStorageRentController.java`、`storagerent/service/preparation/StorageRentPreparationService.java`;查详细索引“自动仓租及出库资金门禁” |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` | | 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` | | 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 | | 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 |
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 | | 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 |
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java`;两级审核、依据失效与零扫码退回规则查当前排车主契约 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | | 实物装载、逐段容量与双端整车发车 | `dispatch/service/DispatchLoadingDepartureService.java`、`DispatchLoadingDocumentService.java`;双端使用 `recordVersion`;装车确认、照片齐全后的自动派送、容量与幂等只查当前排车主契约,无公开 `/depart` |
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` | | 测试 | `src/test/java/com/baiyue/lastleg/` |
+7 -5
View File
@@ -35,8 +35,8 @@
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | | 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 |
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 | | 普通散货收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。FBA仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;普通OVERSEAS按已扫箱码精确核对SKU与逐托实收,可处理多SKU短少,一件代发整托货物拒绝此操作。逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,OVERSEAS已收箱转AVAILABLE、未收箱保留原状态,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
@@ -60,7 +60,7 @@
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购、换标、人工海外费用和仓租通过 `WALLET_COMMAND_REQUESTED` 请求 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得同步调用 Finance 钱包或在 Lastleg 自建余额账本 | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购、换标、人工海外费用和仓租通过 `WALLET_COMMAND_REQUESTED` 请求 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 | | 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 |
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,部分出库只承担原批次份额,余货不重置库龄。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存,按真实出库部分生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` | | 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,中转部分出库按原批次份额计费,退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | | 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
@@ -100,7 +100,7 @@
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 - 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 - 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 - 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 - 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。全车正常派送完成自动结案,结案不改客户状态/费用;结案与车/司机释放占用同事务,提货全部到仓、本土卡车完成也直接释放,不需二次确认。自动释放记录运输完成时刻及路线终点,保留车辆主档状态。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 - 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 - 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。
@@ -136,7 +136,7 @@
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 - 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
- 海外采购按 `procurement-workflow.md` 的七状态流程执行:创建待审核,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。 - 海外采购按 `procurement-workflow.md` 的状态流程执行:客户创建资金处理中,异步冻结成功待审核、失败原单重试,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
@@ -171,3 +171,5 @@
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。 - 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。 - 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。 - 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
- 废弃PDA:无箱唛关联的普通库存先扫本单托盘,再扫描现场箱唛自动累计各明细实际箱数,禁止手填数量绕过扫码。现场箱唛独立保存到 actual_carton_marks_json,不改客户选箱快照;提交校验数量一致、同托及同单跨托盘不重复,失败不落盘、可刷新重扫。客户/平台详情返回 actualCartonMarks、palletNo、实际箱数和原备注。数量申请达到目标后释放未用预留。
@@ -4,6 +4,8 @@
### 当前维护入口(2026-10-02) ### 当前维护入口(2026-10-02)
散板照片运输(2026-11-02):`dispatch.port.LooseboardTransportPort` 由排车声明、`LocalLooseboardService` 实现,排车只输出已经保存照片的运输进度,原单域校验同客户关联及累计送达箱数;不从排车直接访问散板持久化,不生成库存或PDA流水。该依赖按精确类引用登记,不授权其他跨切片实现引用。
现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。 现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。
- 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。 - 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。
@@ -28,6 +30,10 @@
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。 `fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
### 客户原单运输照片读取边界(2026-11-03)
客户订单域通过 `trucking.port.MerchantTransportPhotos` 声明只读照片查询和内容接口,散板及卡车客户 Controller 复用已有原单列表权限与认证客户范围。仅 `dispatch.transport.MerchantTransportPhotoService` 实现此端口,按同客户商业原单的有效排车关联读取散板当前货物/审核轮次照片或本土卡车取货/完成凭证;下载再次核对当前绑定。结构门禁只登记此实现类到端口的精确依赖,不开放客户订单 Controller 到排车具体 Service、Repository 或 Mapper,也不新增包循环。
### 装车资料触发派送边界(2026-10-21) ### 装车资料触发派送边界(2026-10-21)
`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。 `DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。
@@ -4,24 +4,30 @@
## 首版规则 ## 首版规则
仓租项目 `FEE_TYPE_077` 同时支持手动添加。尾程订单与公共费用编辑窗口均展示有效仓租项目;人工仓租沿普通人工费用的审核、结算方式及资金身份处理,可在审核前调整。自动仓租仍以 `createdBy=STORAGE_RENT` 和仓租项目共同识别,继续走出库计租、预占及仓租审核流程;同单同项目补收沿用已有费用提示和原因必填规则。
费用入口为 Admin「海外业务 → 海外费用管理」,仅保留「费用明细」与「仓租库存」两个页签。仓租库存查看相关单据、入库批次、库龄、剩余数量及出库数量记录,不展示金额和审核、调价入口。仓租金额及入库/出库时间、出库批次、计算金额、应扣及实扣金额在费用明细详情查询,多入库批次分别展示实际入库时间。审核与调价统一在对应尾程单据详情的「费用明细」办理,审核前调价必须填写说明。复用 Firstleg 公共费用项目,仓租项目为 `FEE_TYPE_077 / 仓储费`;不建立第二套海外费用目录。人工费用选择真实客户、当前服务业务单、项目、金额与币种后创建,详情保留必要的审核和资金状态。 费用入口为 Admin「海外业务 → 海外费用管理」,仅保留「费用明细」与「仓租库存」两个页签。仓租库存查看相关单据、入库批次、库龄、剩余数量及出库数量记录,不展示金额和审核、调价入口。仓租金额及入库/出库时间、出库批次、计算金额、应扣及实扣金额在费用明细详情查询,多入库批次分别展示实际入库时间。审核与调价统一在对应尾程单据详情的「费用明细」办理,审核前调价必须填写说明。复用 Firstleg 公共费用项目,仓租项目为 `FEE_TYPE_077 / 仓储费`;不建立第二套海外费用目录。人工费用选择真实客户、当前服务业务单、项目、金额与币种后创建,详情保留必要的审核和资金状态。
海外自动仓租的详情、审核和调价共用计算过程展示:来源批次、入库与出库时间、计费规则、实际托数或出库CBM、原库龄、此前已计费天数、各档实际计费日区间、天数×计租基数×快照单价及原币小计,并显示原币金额×保存汇率的人民币换算。新费用把基数与阶梯结果固化在原预占快照中;历史费用只按原报价快照和完整原批次基数解释,旧比例费用明确标识原口径,缺少依据不按最新报价猜算。各档金额保留原始计算精度,费用总额按分四舍五入。
头程附加费审核后推送 Finance 账单。其余海外人工应收创建时冻结余额及可用授信,审核通过后扣款,Finance 成功扣款回执后登记本地已收费用,直接显示已完成,不再推送财务账单;驳回、取消释放冻结。在途回执、失败重试与同键重放不能重复扣款。车辆或同车运输不能代替每笔费用对应的客户和业务订单。 头程附加费审核后推送 Finance 账单。其余海外人工应收创建时冻结余额及可用授信,审核通过后扣款,Finance 成功扣款回执后登记本地已收费用,直接显示已完成,不再推送财务账单;驳回、取消释放冻结。在途回执、失败重试与同键重放不能重复扣款。车辆或同车运输不能代替每笔费用对应的客户和业务订单。
现有尾程订单没有权威的“原报价已含派送费”字段。首版新增头程派送费仍由审核人员核对原报价,已包含则驳回该费用;系统尚不支持自动判断报价包含关系,审核提示不能替代人工核对。 现有尾程订单没有权威的“原报价已含派送费”字段。首版新增头程派送费仍由审核人员核对原报价,已包含则驳回该费用;系统尚不支持自动判断报价包含关系,审核提示不能替代人工核对。
头程货最终目的地为自有海外仓、仓储转运(含外来货)以及退货建立计租批次。仓租全部使用余额及可用授信。实际 PDA 入库开始、实际 PDA 出库停止本次出库份额计租,每24小时计一天,不足24小时按一天。余货保持原入库时刻,不重置免费期;移库、换标和拆合托不新增免费期。 头程货最终目的地为自有海外仓、仓储转运(含外来货)以及退货建立计租批次。仓租全部使用余额及可用授信。实际 PDA 入库开始,每24小时计一天,不足24小时按一天。中转按实际出库份额停止计租;退货按原托整托收费,原托剩余一箱仍计一托,直到清空才停止计租。余货保持原入库时刻,不重置免费期;移库、换标和拆合托不新增免费期。
头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。 头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。
| 规则 | 计费阶梯(USD) | 数量口径 | | 规则 | 计费阶梯(USD) | 数量口径 |
|---|---|---| |---|---|---|
| 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 | | 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 |
| 退货仓租 | 第1–30天1.5;31–60天2;61–90天3;91天起4 | 原始托数向上取整,按本次出库占原托比例分摊 | | 退货仓租 | 第1–30天1.5;31–60天2;61–90天3;91天起4 | USD/原托/天,不足一托按一托;部分出库不按件分摊,同托同计租日仅收一次 |
自动计租优先读取现有客户仓租配置;无客户配置时使用上述两张报价表。USD 配置缺少整组中转或退货阶梯时,缺失组补用对应默认报价;非 USD 配置不得直接补用美元价格。已配置阶梯须从第1天连续覆盖至无限期,有限尾阶梯、缺段、负价或不支持的币种均阻断计价,不将配置错误或服务异常当作默认报价。 自动计租优先读取现有客户仓租配置;无客户配置时使用上述两张报价表。USD 配置缺少整组中转或退货阶梯时,缺失组补用对应默认报价;非 USD 配置不得直接补用美元价格。已配置阶梯须从第1天连续覆盖至无限期,有限尾阶梯、缺段、负价或不支持的币种均阻断计价,不将配置错误或服务异常当作默认报价。
每次出库资金预校验保存客户、配置编号及版本、币种、完整阶梯和计算结果快照。有效预占和实际扣款使用原快照;修改配置不重算已冻结或已生成的费用。后续新预占按最新客户配置计算本次出库份额的全部库龄,仍保留最初入库时间;本版不把一次出库按历次调价日期再拆段计费。过期或货物、计租天数等依据变化时须重新预校验。 每次出库资金预校验保存客户、配置编号及版本、币种、完整阶梯和计算结果快照。有效预占和实际扣款使用原快照;修改配置不重算已冻结或已生成的费用。中转新预占按最新客户配置计算本次出库份额的全部库龄;退货新预占只计算原托尚未生成仓租的期间。两者都保留最初入库时间,按原库龄匹配阶梯,不按历次调价日期再拆段计费。过期或货物、计租天数、退货原托已计费截止日变化时须重新预校验并释放旧冻结。
退货原托的 `charged_days` 在实际出库与待审核费用生成的同一事务内推进,预占、取消、资金回执不推进。待审核费用已覆盖的期间不重复生成;审核减免或驳回属于该期间的人工处理,不因后续出库再次收取。一个扫码含同原托多个箱或 SKU 时先按原托去重;多原托分别收费。商品份额仅追踪库存数量与来源。并发扫码发现其他出库已推进截止日时阻断旧预占,重新预校验后只冻结未覆盖期间的金额。
原币金额与人民币结算额分别保留,冻结时固定汇率快照。无有效汇率时不得将美元直接作为人民币扣款。免费期零仓租保留出库记录,不要求汇率、不冻结资金。仓租自动计算,出库仅登记待审核费用并保留冻结;批准后沿原资金身份按实额扣款,驳回释放原冻结。订单费用和海外费用管理均展示原批次实际入库、实际出库、原计算金额、应扣金额及实扣金额。审核前允许原币金额调整且说明必填,保存版本;调低释放多余占用,调高由Finance结算时原子检查差额,余额不足保留原冻结,不登记实扣。原零元改非零先补冻结,成功后才能审核。其余客户收费人工录入并审核。历史已扣费用保留结果,不补审、不重复扣款。 原币金额与人民币结算额分别保留,冻结时固定汇率快照。无有效汇率时不得将美元直接作为人民币扣款。免费期零仓租保留出库记录,不要求汇率、不冻结资金。仓租自动计算,出库仅登记待审核费用并保留冻结;批准后沿原资金身份按实额扣款,驳回释放原冻结。订单费用和海外费用管理均展示原批次实际入库、实际出库、原计算金额、应扣金额及实扣金额。审核前允许原币金额调整且说明必填,保存版本;调低释放多余占用,调高由Finance结算时原子检查差额,余额不足保留原冻结,不登记实扣。原零元改非零先补冻结,成功后才能审核。其余客户收费人工录入并审核。历史已扣费用保留结果,不补审、不重复扣款。
@@ -30,12 +36,13 @@
## 计租验收样例 ## 计租验收样例
1. 中转批次10箱共2CBM,第10天出4箱:3个收费日 × 0.8CBM × 0.8 = **1.92 USD**;余6箱第31天出库:(23×0.8+1×1) × 1.2CBM = **23.28 USD**。余货仍从最初入库计龄。 1. 中转批次10箱共2CBM,第10天出4箱:3个收费日 × 0.8CBM × 0.8 = **1.92 USD**;余6箱第31天出库:(23×0.8+1×1) × 1.2CBM = **23.28 USD**。余货仍从最初入库计龄。
2. 退货原批次不足一托,100件按1托,第10天出40件:10×1.5×40%=**6 USD**;第40天出余60件:(30×1.5+10×2)×60%=**39 USD**。第二次出库不再次将剩余货物进位到1托。 2. 退货原批次不足一托,100件按1托,第10天出40件:10×1.5=**15 USD**;同一计租日再出部分货不重复收费;剩余一箱仍为一托,第40天清空时补收第11–40天:20×1.5+10×2=**50 USD**,整托40天合计**65 USD**。第61天或第91天仍有货时,新增日分别按3或4 USD/托计费。
3. 入库后恰好24小时为1天,24小时加1毫秒为2天;恰好7天仍免费,7天加1毫秒进入第8天。对金额先按份额精算,完整收费命令汇总后按结算精度取整。 3. 入库后恰好24小时为1天,24小时加1毫秒为2天;恰好7天仍免费,7天加1毫秒进入第8天。对金额先按份额精算,完整收费命令汇总后按结算精度取整。
## 发布配套 ## 发布配套
- 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。 - 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。
- 退货整托口径更新:先对已有 Lastleg 库执行 `deploy/sql/return-storage-rent-whole-pallet.sql` 补充 `charged_days`,再配套发布 Lastleg。新库两份基线已同步。切换时暂停相关出库并取消未消费预占;既有比例计费记录需逐单确认处理方式,脚本不回填计费截止日、不追收或修改既有费用和资金。当前仅准备源码和脚本,未据此认定 TEST 已生效。
- 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。 - 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。
- 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。 - 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。
- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。指定 TEST 先使用[表与审核人字段补齐](../../deploy/sql/overseas-fees-storage-rent-structure-add.sql),再使用[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql);精确范围与失败语义只维护在[数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。 - 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。指定 TEST 先使用[表与审核人字段补齐](../../deploy/sql/overseas-fees-storage-rent-structure-add.sql),再使用[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql);精确范围与失败语义只维护在[数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。
@@ -70,7 +77,7 @@
| 人工海外收费 | 创建只冻结;审核后恰好扣一次;驳回/取消释放;不足余额但授信足够可成功,二者合计不足明确失败 | | 人工海外收费 | 创建只冻结;审核后恰好扣一次;驳回/取消释放;不足余额但授信足够可成功,二者合计不足明确失败 |
| 头程附加费 | 审核前不入账单,批准后回到正确头程来源且不扣钱包;人工核对原报价,已含派送费的新增收费须驳回 | | 头程附加费 | 审核前不入账单,批准后回到正确头程来源且不扣钱包;人工核对原报价,已含派送费的新增收费须驳回 |
| 仓租原生入库 | 头程自有仓、外来转运和退货实际扫码均生成正确原批次;非计租范围不误收 | | 仓租原生入库 | 头程自有仓、外来转运和退货实际扫码均生成正确原批次;非计租范围不误收 |
| 部分及分批出库 | 原托比例、阶梯、库龄和余量符合上例;整托/多箱/混SKU全部来源核对后才能扣库存 | | 部分及分批出库 | 中转保持来源比例;退货余一箱仍按整托,新期间按原库龄阶梯,同托同日不重复收费;整托/多箱/混SKU全部来源核对后才能扣库存 |
| 客户价格与默认报价 | 客户专价优先;无配置按两张默认报价;接口超时或无效阶梯不允许默认为免费或默认报价 | | 客户价格与默认报价 | 客户专价优先;无配置按两张默认报价;接口超时或无效阶梯不允许默认为免费或默认报价 |
| 调价与币种 | 有效预占及已生成费用不受调价影响,新预占使用新版本;USD、CNY 原币及分币种汇总准确,不将混合币种直接相加 | | 调价与币种 | 有效预占及已生成费用不受调价影响,新预占使用新版本;USD、CNY 原币及分币种汇总准确,不将混合币种直接相加 |
| 资金门禁 | 未冻结成功、资金不足、过期、未知网络结果均不放行库存写;成功后仅本次实际出库份额扣款 | | 资金门禁 | 未冻结成功、资金不足、过期、未知网络结果均不放行库存写;成功后仅本次实际出库份额扣款 |
+16 -5
View File
@@ -4,7 +4,9 @@
| 主状态 | 编码 | 操作 | | 主状态 | 编码 | 操作 |
|---|---|---| |---|---|---|
| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 | | 资金处理中 | `FUNDING_PENDING` | 订单已受理,等待Finance冻结回执 |
| 资金失败 | `FUNDING_FAILED` | 展示失败原因,可在原订单重试 |
| 待审核 | `PENDING_REVIEW` | 冻结成功后,平台审核并确定采购仓 |
| 收货中 | `RECEIVING` | 平台分批登记数量和照片 | | 收货中 | `RECEIVING` | 平台分批登记数量和照片 |
| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 | | 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 |
| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 | | 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 |
@@ -14,13 +16,13 @@
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。 拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。重新上传成功后清除当前驳回原因,本轮其他批次仍可继续换图;`photosNeedUpdate` 只标识仍绑定本轮驳回照片,不作为重新提交门禁。再次提交采购完成成功后清除上一轮驳回原因和照片基线,待客户确认及确认后的页面均不展示旧驳回提示。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。 出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。 主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。每次创建出库批次前,本采购主单必须至少有一项未作废应收费用,且全部未作废应收费用均须有效并审核通过;缺少费用、待审核或驳回时提示并阻止出库,前后端共同校验。应付与已作废费用不能替代应收费用明细;原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。 费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。费用明细缺失或尚未审核时可提示运营补充,但不阻止创建出库批次;出库资格只由采购状态、可用数量、物流跟踪号及操作权限决定。原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。 客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。
@@ -28,13 +30,13 @@
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。 2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
- 创建采购单后为待审核,审核后直接收货中。 - 客户创建采购单后先资金处理中,冻结成功进入待审核,失败保留原单重试;审核后直接收货中。
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。 - 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。 - 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。 - 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。 - 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。
- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。 - 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。
- 无费用、仅作废费用、仅应付费用或任一应收尚未审核通过时,不能创建出库;全部有效应收审核通过后允许出库。 - 无费用、费用尚未审核或驳回时均允许创建出库;页面提示不影响出库操作。
自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。 自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。
@@ -46,3 +48,12 @@
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。 - Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。 - 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。 - 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。
## 2026-10-29 客户下单预算与页面调整
客户采购直接提交创建请求;预算预占、FUNDING_PENDING订单及冻结Outbox在同一事务落库,响应表示订单已受理。Finance冻结成功后进入PENDING_REVIEW,明确失败进入FUNDING_FAILED,展示失败原因并允许以原订单、原金额及币种重试。处理中重复点击不重复冻结,旧版本回执不能推进新指令。免费样品零金额不发资金指令并直接进入待审核。旧预算预校验接口仅兼容保留,客户端不再等待冻结后建单。未绑定预占15分钟后释放,已绑定订单终态释放,失败定时重试;迟到回执不得复活取消订单。预算预占独立于人工费用冻结/实扣,不计入已扣款汇总。
本土卡车采用相同的先受理后异步冻结流程,平台仅可审核PENDING_REVIEW订单。托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。
结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。
@@ -76,12 +76,14 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture' && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService', || ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService', 'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService',
'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService',
'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController', 'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController',
'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source) 'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source)
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort' && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort'
|| [ || [
'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels', 'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.FleetTripModels',
'TransportTaskProjection->trip.TransportTripChanged',
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService', 'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
'TransportTaskService->reporting.TripOperationsEvidenceGateway', 'TransportTaskService->reporting.TripOperationsEvidenceGateway',
'TransportTaskService->photo.FleetResourcePhotoModels', 'TransportTaskService->photo.FleetResourcePhotoModels',
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.appointmentplanning;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.stream.Collectors;
/** 读取原货物的货件资料,始终按客户及订单身份隔离。 */
@Repository
public class AppointmentCargoReferenceRepository {
private final AppointmentCargoVerificationMapper mapper;
/** 复用预约货物 Mapper,不跨服务或跨库补读申报。 */
public AppointmentCargoReferenceRepository(AppointmentCargoVerificationMapper mapper) { this.mapper = mapper; }
/** 一次批读本次明确货源的原货件号,未实收建单的外接货保持空列表。 */
public Map<List<String>, List<String>> originalReferences(List<AppointmentSourceService.Source> sources) {
var orders = sources.stream().filter(source -> source.orderId() != null)
.map(source -> new AppointmentCoverageMapper.OrderRef(source.customerId(), source.orderId())).distinct().toList();
var byOrder = orders.isEmpty() ? Map.<AppointmentCoverageMapper.OrderRef, List<AppointmentCargoVerificationMapper.ShipmentReference>>of()
: mapper.originalShipmentReferences(orders).stream().collect(Collectors.groupingBy(
row -> new AppointmentCoverageMapper.OrderRef(row.customerId(), row.orderId())));
var result = new HashMap<List<String>, List<String>>();
for (var source : sources) {
var references = byOrder.getOrDefault(new AppointmentCoverageMapper.OrderRef(source.customerId(), source.orderId()), List.of())
.stream().map(AppointmentCargoVerificationMapper.ShipmentReference::shipmentReference).filter(Objects::nonNull)
.map(String::trim).filter(value -> !value.isEmpty()).distinct().sorted().toList();
result.put(List.of(source.customerId(), source.sourceKey()), references);
}
return Map.copyOf(result);
}
}
@@ -8,6 +8,23 @@ import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface AppointmentCargoVerificationMapper extends BaseMapper<AppointmentCargoVerification> { public interface AppointmentCargoVerificationMapper extends BaseMapper<AppointmentCargoVerification> {
record ShipmentReference(String customerId, Long orderId, String shipmentReference) { }
/** 按已授权的客户与原订单读取原有货件号和追踪号,仓储转运读取其正式来源。 */
@Select({"<script>",
"SELECT o.customer_id AS customerId,o.id AS orderId,",
"CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.fba_shipment_no ELSE d.shipment_no END AS shipmentReference ",
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id AND o.order_type&lt;&gt;'WAREHOUSE_TRANSFER' ",
AppointmentCoverageMapper.TRANSFER_JOIN,
"WHERE <foreach collection='orders' item='o' open='(' separator=' OR ' close=')'>(o.id=#{o.orderId} AND o.customer_id=#{o.customerId})</foreach>",
"UNION ALL SELECT o.customer_id AS customerId,o.id AS orderId,",
"CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.tracking_no ELSE d.fba_tracking_no END AS shipmentReference ",
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id AND o.order_type&lt;&gt;'WAREHOUSE_TRANSFER' ",
AppointmentCoverageMapper.TRANSFER_JOIN,
"WHERE <foreach collection='orders' item='o' open='(' separator=' OR ' close=')'>(o.id=#{o.orderId} AND o.customer_id=#{o.customerId})</foreach>",
"</script>"})
java.util.List<ShipmentReference> originalShipmentReferences(@Param("orders") java.util.List<AppointmentCoverageMapper.OrderRef> orders);
/** Same canonical source row used by allocation; flush pre-lock observations before re-reading. */ /** Same canonical source row used by allocation; flush pre-lock observations before re-reading. */
@Select("SELECT id FROM ll_order WHERE id=#{orderId} FOR UPDATE") @Select("SELECT id FROM ll_order WHERE id=#{orderId} FOR UPDATE")
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false) @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
@@ -21,10 +21,12 @@ public class AppointmentCargoVerificationService {
private final AppointmentSourceService sources; private final AppointmentSourceService sources;
private final AppointmentCargoVerificationMapper mapper; private final AppointmentCargoVerificationMapper mapper;
private final ObjectMapper json; private final ObjectMapper json;
private final AppointmentCargoReferenceRepository references;
/** 核实记录与原货物资料分别读取,已有货件号无需重复录入。 */
public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper,
ObjectMapper json) { ObjectMapper json, AppointmentCargoReferenceRepository references) {
this.sources=sources; this.mapper=mapper; this.json=json; this.sources=sources; this.mapper=mapper; this.json=json; this.references=references;
} }
public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion, public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion,
@Min(0) int expectedVersion, @Positive Integer packageCount, @Min(0) int expectedVersion, @Positive Integer packageCount,
@@ -34,7 +36,7 @@ public class AppointmentCargoVerificationService {
Integer packageCount, List<String> shipmentReferences, String remark, Integer packageCount, List<String> shipmentReferences, String remark,
String operatorName, Instant verifiedAt) { } String operatorName, Instant verifiedAt) { }
public record Context(AppointmentSourceService.Source source, Verification verification, public record Context(AppointmentSourceService.Source source, Verification verification,
long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, List<String> effectiveShipmentReferences,
Long availablePackages, boolean sourceChanged, List<String> allowedActions, Long availablePackages, boolean sourceChanged, List<String> allowedActions,
List<String> blockReasons) { } List<String> blockReasons) { }
@@ -50,12 +52,13 @@ public class AppointmentCargoVerificationService {
public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) {
public Context requireContext() { if(error!=null) throw error; return context; } public Context requireContext() { if(error!=null) throw error; return context; }
} }
/** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */ /** 在本次批读内共用来源、仓库及原货件资料,不缓存可变业务事实。 */
public List<Inspection> inspectBatch(List<AppointmentCoverageService.SourceRef> refs) { public List<Inspection> inspectBatch(List<AppointmentCoverageService.SourceRef> refs) {
var result=new ArrayList<Inspection>(); var result=new ArrayList<Inspection>();
for(int start=0;start<refs.size();start+=100) { for(int start=0;start<refs.size();start+=100) {
var batch=refs.subList(start,Math.min(start+100,refs.size())); var batch=refs.subList(start,Math.min(start+100,refs.size()));
var resolved=sources.inspectBatch(batch); var resolved=sources.inspectBatch(batch);
var originals=originalShipmentReferences(resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::source).toList());
var valid=resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::reference).toList(); var valid=resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::reference).toList();
var latest=new HashMap<List<String>,AppointmentCargoVerification>(); var latest=new HashMap<List<String>,AppointmentCargoVerification>();
var allocations=new HashMap<List<String>,AppointmentCargoVerificationMapper.Allocation>(); var allocations=new HashMap<List<String>,AppointmentCargoVerificationMapper.Allocation>();
@@ -65,18 +68,19 @@ public class AppointmentCargoVerificationService {
} }
for(var resolution:resolved) { for(var resolution:resolved) {
var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey()); var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey());
result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))):null,resolution.error())); result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0)),originals.getOrDefault(key,List.of())):null,resolution.error()));
} }
} }
return List.copyOf(result); return List.copyOf(result);
} }
/** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */ /** 使用已授权来源读取本票核实记录及原有货件资料。 */
public Context inspect(AppointmentSourceService.Source source) { public Context inspect(AppointmentSourceService.Source source) {
var row=mapper.latest(source.customerId(),source.sourceKey()); var row=mapper.latest(source.customerId(),source.sourceKey());
var allocation=mapper.allocated(source.customerId(),source.sourceKey()); var allocation=mapper.allocated(source.customerId(),source.sourceKey());
return inspect(source,row,allocation); return inspect(source,row,allocation,originalShipmentReferences(List.of(source)).getOrDefault(List.of(source.customerId(),source.sourceKey()),List.of()));
} }
private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation) { /** 已保存的货件核实值优先;尚未补录货件时带入原货物的已有编号。 */
private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation,List<String> originals) {
boolean changed=row!=null && changed(row,source); boolean changed=row!=null && changed(row,source);
Integer total=row==null?source.packages():row.getPackageCount(); Integer total=row==null?source.packages():row.getPackageCount();
var reasons=new ArrayList<>(source.blockReasons()); var reasons=new ArrayList<>(source.blockReasons());
@@ -88,10 +92,17 @@ public class AppointmentCargoVerificationService {
?null:total.longValue()-allocation.packages(); ?null:total.longValue()-allocation.packages();
// Actions describe this endpoint's business capability; controller permissions remain mandatory. // Actions describe this endpoint's business capability; controller permissions remain mandatory.
var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.<String>of(); var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.<String>of();
return new Context(source,row==null?null:view(row),allocation.packages(),allocation.unknownItems(),total, var verified=row==null?null:view(row);
var effectiveReferences=verified==null || verified.shipmentReferences().isEmpty()?originals:verified.shipmentReferences();
return new Context(source,verified,allocation.packages(),allocation.unknownItems(),total,effectiveReferences,
available,changed,actions,List.copyOf(reasons)); available,changed,actions,List.copyOf(reasons));
} }
/** 对预约明细和核实表单提供同一批原始货件资料,不改写原订单。 */
public Map<List<String>,List<String>> originalShipmentReferences(List<AppointmentSourceService.Source> sources) {
return references.originalReferences(sources);
}
@Transactional(isolation=Isolation.READ_COMMITTED) @Transactional(isolation=Isolation.READ_COMMITTED)
public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) { public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) {
validate(request,operatorId); validate(request,operatorId);
@@ -20,6 +20,7 @@ public class AppointmentCoverageService {
this.mapper=mapper; this.external=external; this.mapper=mapper; this.external=external;
} }
/** 按真实货物与外部约号核对覆盖,确认时段缺失不伪造时间也不撤销已确认事实。 */
public List<Coverage> query(List<SourceRef> input) { public List<Coverage> query(List<SourceRef> input) {
if (input==null || input.isEmpty()) return List.of(); if (input==null || input.isEmpty()) return List.of();
if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) { if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) {
@@ -85,7 +86,7 @@ public class AppointmentCoverageService {
for (var fact:relevant) { for (var fact:relevant) {
if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { invalid=true; continue; } if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { invalid=true; continue; }
covered+=fact.packages(); covered+=fact.packages();
boolean success="SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null && !blank(fact.confirmationNo()) && !blank(fact.timezone()); boolean success="SUCCESS".equals(fact.status()) && !blank(fact.confirmationNo()) && !blank(fact.timezone());
if (success) confirmed+=fact.packages(); if (success) confirmed+=fact.packages();
distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone())); distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone()));
changed|=!Objects.equals(currentVersion,fact.sourceVersion()); changed|=!Objects.equals(currentVersion,fact.sourceVersion());
@@ -99,7 +100,8 @@ public class AppointmentCoverageService {
else if (confirmed<packages) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); } else if (confirmed<packages) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
else { status="CONFIRMED"; } else { status="CONFIRMED"; }
if (relevant.stream().anyMatch(f->"SUCCESS".equals(f.status()) && blank(f.timezone()))) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED"); if (relevant.stream().anyMatch(f->"SUCCESS".equals(f.status()) && blank(f.timezone()))) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED");
if (evidence==null ? order.shipmentReferencesMissing() : !hasReferences(evidence.getShipmentReferencesJson())) missing.add("FBA_SHIPMENT_REFERENCES"); // 只补录过箱数的核实记录不能遮蔽原订单已经完整提供的货件号。
if (order.shipmentReferencesMissing() && (evidence==null || !hasReferences(evidence.getShipmentReferencesJson()))) missing.add("FBA_SHIPMENT_REFERENCES");
if (changed) missing.add("SOURCE_CHANGED_REVIEW"); if (changed) missing.add("SOURCE_CHANGED_REVIEW");
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing), output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing),
(booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid)); (booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid));
@@ -7,15 +7,19 @@ import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List; import java.util.List;
/** Resolves a location's appointment window without using the operator's system time zone. */ /** 解析预约地点的真实确认时段,不使用操作人的系统时区或虚构确认时间。 */
public final class AppointmentTimePolicy { public final class AppointmentTimePolicy {
private AppointmentTimePolicy() { } private AppointmentTimePolicy() { }
public record Window(Instant start, Instant end, String timezone) { } public record Window(Instant start, Instant end, String timezone) { }
/** 未提供确认时段时保持为空;提供时仍校验地区时区、夏令时及先后顺序。 */
public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone, public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone,
ZoneOffset startOffset, ZoneOffset endOffset) { ZoneOffset startOffset, ZoneOffset endOffset) {
if (start == null) throw new IllegalArgumentException("请填写预约开始时间"); if (start == null) {
if (end != null || startOffset != null || endOffset != null) throw new IllegalArgumentException("未填写确认开始时间,不能填写确认结束时间或时差");
return new Window(null, null, timezone);
}
if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区"); if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区");
final ZoneId zone; final ZoneId zone;
try { zone = ZoneId.of(timezone); } try { zone = ZoneId.of(timezone); }
@@ -18,6 +18,7 @@ public interface AppointmentWorkDispatchPort {
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates); Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */ /** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items); Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items);
/** 正式确认的已知时段原样返回;未提供确认时间时保持为空。 */
record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages, record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages,
String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { } String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { }
record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount, record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount,
@@ -29,11 +30,16 @@ public interface AppointmentWorkDispatchPort {
/** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */ /** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */
void bindItems(long dispatchId,List<PhysicalItem> touchedItems,List<Allocation> allocations); void bindItems(long dispatchId,List<PhysicalItem> touchedItems,List<Allocation> allocations);
void requireDeparture(String dispatchNo); void requireDeparture(String dispatchNo);
/** 纯散板照片发运锁定原预约并核对计划或实收来源,不要求仓内拣货。 */
void requirePhotoDeparture(String dispatchNo);
/** 混排仅指定权威异地取货明细按计划核验,其余明细仍核验实际拣货。 */
void requireMixedDeparture(String dispatchNo,java.util.Set<Long> remoteItemIds);
String mixedDepartureBlockReason(String dispatchNo,java.util.Set<Long> remoteItemIds);
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */ /** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
String departureBlockReason(String dispatchNo); String departureBlockReason(String dispatchNo);
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo, record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { } java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
/** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */ /** FBA选入前须已有正式外部确认;提交审核、审核通过和开始拣货继续复核运输安排及已知时段。 */
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt); String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
List<ReceivingWindow> reviewEvidence(String dispatchNo); List<ReceivingWindow> reviewEvidence(String dispatchNo);
void removeItem(long dispatchId,long itemId); void removeItem(long dispatchId,long itemId);
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** FBA按数量实收库存的原箱唛只读来源。 */
@FeignClient(name="firstleg-service", contextId="firstlegCartonMarksClient", url="${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}")
public interface FirstlegCartonMarksClient {
/** 以可信库存来源及客户身份读取有效批次。 */
@GetMapping("/internal/firstleg/v1/inbounds/{inboundId}/existing-carton-marks")
ApiResponse<List<String>> marks(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("inboundId") long inboundId, @RequestParam("customerId") long customerId,
@RequestParam("inboundCode") String inboundCode);
}
@@ -39,6 +39,11 @@ public class DispatchDeliveryRepository {
public void create(OutboundOrder value) { outbounds.insert(value); } public void create(OutboundOrder value) { outbounds.insert(value); }
/** 不可变出库来源明细在同一安排事务保存。 */ /** 不可变出库来源明细在同一安排事务保存。 */
public void create(OutboundPalletItem value) { items.insert(value); } public void create(OutboundPalletItem value) { items.insert(value); }
/** 实际扫码归属核平后,原计划明细统一替换为实际来源明细。 */
public void replaceItems(Long outboundId) {
items.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<OutboundPalletItem>()
.eq(OutboundPalletItem::getOutboundOrderId,outboundId));
}
/** 每客户目的地分别保存派送单。 */ /** 每客户目的地分别保存派送单。 */
public void create(DeliveryOrder value) { deliveries.insert(value); } public void create(DeliveryOrder value) { deliveries.insert(value); }
/** 仅更新已锁定并复验的客户出库执行事实。 */ /** 仅更新已锁定并复验的客户出库执行事实。 */
@@ -58,14 +63,22 @@ public class DispatchDeliveryRepository {
/** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */ /** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */
public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); } public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); }
/** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */ /** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */
public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now) { public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now,boolean outboundCompleted) {
if (sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId()) if (sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId())
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo) .eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
.set(LastlegOrder::getDeliveryOrderNo,deliveryNo) .set(LastlegOrder::getDeliveryOrderNo,deliveryNo)
.set(LastlegOrder::getSubOrderStatus,"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY") .set(LastlegOrder::getSubOrderStatus,outboundCompleted?"OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY":"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY")
.set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1) .set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1)
.set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化"); .set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化");
} }
/** PDA拣货核平即完成出库节点,保留尚未生成的派送单为空。 */
public void markOutbound(LastlegOrder source,String outboundNo,java.time.LocalDateTime now) {
if(sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId())
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
.set(LastlegOrder::getSubOrderStatus,"OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY")
.set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1)
.set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("出库来源状态已变化");
}
/** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */ /** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */
public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); } public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); }
} }
@@ -26,7 +26,7 @@ import java.util.stream.Collectors;
/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */ /** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */
@Service @Service
public class DispatchDeliveryService implements DispatchDeliveryPort { public class DispatchDeliveryService implements DispatchDeliveryPort, com.baiyue.lastleg.dispatch.port.DispatchOutboundPort {
private final DispatchExecutionPort execution; private final DispatchExecutionPort execution;
private final DispatchDeliveryRepository repository; private final DispatchDeliveryRepository repository;
private final DeliveryCargoGroups grouping; private final DeliveryCargoGroups grouping;
@@ -118,9 +118,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复"); for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复");
if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地"); if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地");
// 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。 // 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。
var existingOutbounds=repository.outbounds(context.dispatchId(),true); var existingOutbounds=activeOutbounds(context.dispatchId(),true);
var existingDeliveries=repository.deliveries(context.dispatchId(),true); var existingDeliveries=repository.deliveries(context.dispatchId(),true);
if (!existingOutbounds.isEmpty() || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对"); if (existingOutbounds.stream().anyMatch(o->!"OUTBOUNDED".equals(o.getStatus())) || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对");
var external=fees.external(context);var coverages=new HashMap<String,DeliveryFeeCoverageModels.Evidence>(); var external=fees.external(context);var coverages=new HashMap<String,DeliveryFeeCoverageModels.Evidence>();
for (var group:groups) { for (var group:groups) {
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId())); var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
@@ -153,7 +153,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
return fact(saved); return fact(saved);
} }
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。 // 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty()) if(activeOutbounds(context.dispatchId(),true).stream().anyMatch(o->!"OUTBOUNDED".equals(o.getStatus())) || !repository.deliveries(context.dispatchId(),true).isEmpty())
throw conflict("已存在关联单据但缺少完整安排事实,请核对"); throw conflict("已存在关联单据但缺少完整安排事实,请核对");
var groups=grouping.groups(context);var external=fees.external(context); var groups=grouping.groups(context);var external=fees.external(context);
var inputs=new HashMap<String,ArrangeGroup>(); var inputs=new HashMap<String,ArrangeGroup>();
@@ -167,13 +167,97 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of()); return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of());
} }
/** 理货审核后只创建待出库单,不提前生成派送、费用或真实拣货事实。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public void prepare(Context context, Actor actor) {
if(context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING || context.pickingReconciled()
|| context.cargo().isEmpty() || context.cargo().stream().mapToLong(CargoLine::packages).sum()!=context.counts().packageCount())
throw conflict("理货审核货物不完整,不能生成出库单");
var existing=repository.outbounds(context.dispatchId(),true);
if(existing.stream().anyMatch(o->!"CANCELLED".equals(o.getStatus())))throw conflict("本车已有有效出库单");
for(var customer:context.cargo().stream().map(CargoLine::customerId).distinct().sorted().toList()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
var order=existing.stream().filter(o->customer.equals(o.getCustomerId())).findFirst().orElse(null);
boolean created=order==null;
if(created) {order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);order.setOutboundNo(numbers.next("OUTBOUND"));
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
order.setOutboundType("DISPATCH_DELIVERY");order.setLxCodes("");order.setCreatedBy(actor.operatorId());order.setCreatedAt(local(Instant.now()));
order.setIdempotencyKey(grouping.hash(List.of("REVIEW_OUTBOUND",context.dispatchId(),customer)));}
else repository.replaceItems(order.getId());
order.setStatus("WAIT_OUTBOUND");order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum()));
order.setPalletCount((int)cargo.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count());
order.setGrossWeightKg(metric(cargo,CargoLine::grossWeightKg));order.setVolumeCbm(metric(cargo,CargoLine::volumeCbm));
order.setRequestHash(context.cargoHash());order.setUpdatedAt(local(Instant.now()));
if(created)repository.create(order);else repository.save(order);
for(var line:cargo)repository.create(outboundItem(order,line,false));
}
}
/** PDA 全部来源核平后,把对应客户出库单推进为已出库,发车阶段只推进派送单。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public void complete(Context context) {
if(!context.pickingReconciled() || context.cargo().stream().anyMatch(c->c.attributionId()==null))throw conflict("真实拣货来源尚未核平");
var outbounds=activeOutbounds(context.dispatchId(),true);
if(outbounds.isEmpty())throw conflict("理货审核出库单缺失");
for (var outbound:outbounds) {
var cargo=context.cargo().stream().filter(c->outbound.getCustomerId().equals(c.customerId())).toList();
if(cargo.stream().mapToLong(CargoLine::packages).sum()!=outbound.getPackageCount())throw conflict("出库单与真实客户拣货数量不一致");
repository.replaceItems(outbound.getId());
for(var line:cargo)repository.create(outboundItem(outbound,line,true));
outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(Instant.now()));repository.save(outbound);
var now=local(Instant.now());
for(var orderId:cargo.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList()) {
var source=repository.lockSource(outbound.getCustomerId(),orderId);
if(source==null)throw conflict("真实拣货来源订单缺失");
repository.markOutbound(source,outbound.getOutboundNo(),now);
}
}
}
/** 零扫码退回时撤销审核阶段预生成的下游单据。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public void revoke(long dispatchId) {
for (var outbound: repository.outbounds(dispatchId, true)) {
if(!Set.of("WAIT_OUTBOUND","CANCELLED").contains(outbound.getStatus()))throw conflict("已出库不能退回重审");
outbound.setStatus("CANCELLED");outbound.setUpdatedAt(local(Instant.now()));repository.save(outbound);
}
}
/** 预生成和实拣共用字段映射,计划行永远不携带虚构拣货数量。 */
private OutboundPalletItem outboundItem(OutboundOrder order,CargoLine row,boolean picked) {
var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(order.getId());
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId());
item.setWarehousePalletId(row.warehousePalletId());item.setPalletNo(row.palletNo());item.setInboundCode(row.inboundCode());
item.setDestinationWarehouseId(row.destination().warehouseId());item.setDestinationWarehouseCode(row.destination().warehouseCode());item.setDestinationWarehouseName(row.destination().warehouseName());
item.setPackageCount(Math.toIntExact(row.packages()));item.setPickedPackageCount(picked?Math.toIntExact(row.packages()):0);item.setStatus(picked?"PICKED":"WAIT_PICK");
item.setGrossWeightKg(row.grossWeightKg());item.setVolumeCbm(row.volumeCbm());item.setCartonMarks(row.cartonMark());item.setOrderRefs(row.orderNo());item.setCreatedAt(local(Instant.now()));
return item;
}
/** 已撤销的审核出库保留审计,但不参与本轮有效单据集合校验。 */
private List<OutboundOrder> activeOutbounds(long id,boolean lock) {
return repository.outbounds(id,lock).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList();
}
/** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */ /** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */
private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups, private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups,
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external, Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) { Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>(); var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
var prepared=activeOutbounds(context.dispatchId(),true);
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
var existing=prepared.stream().filter(o->customer.equals(o.getCustomerId())).findFirst().orElse(null);
if(existing!=null) {
if(!"OUTBOUNDED".equals(existing.getStatus()) || existing.getPackageCount()!=cargo.stream().mapToLong(CargoLine::packages).sum())
throw conflict("审核出库单尚未按真实拣货完成");
outbounds.put(customer,existing);continue;
}
var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer); var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
order.setOutboundNo(numbers.next("OUTBOUND")); order.setOutboundNo(numbers.next("OUTBOUND"));
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
@@ -193,6 +277,8 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now); var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now);
repository.create(delivery);deliveries.put(group.key(),delivery); repository.create(delivery);deliveries.put(group.key(),delivery);
for (var row:group.cargo()) { for (var row:group.cargo()) {
// 提前创建的出库单已在PDA拣货核平时写入实际归属,发运不能再插一次。
if(prepared.stream().anyMatch(o->Objects.equals(o.getId(),outbound.getId())))continue;
var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId()); var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId());
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId()); item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId()); item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId());
@@ -224,12 +310,12 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public DepartureFacts departureFacts(Context context,boolean lock) { public DepartureFacts departureFacts(Context context,boolean lock) {
var arrangement=repository.arrangement(context.dispatchId(),lock); var arrangement=repository.arrangement(context.dispatchId(),lock);
var outbounds=repository.outbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock); var outbounds=activeOutbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock);
var groups=context.cargo()==null||context.cargo().isEmpty()?List.<Group>of():grouping.groups(context); var groups=context.cargo()==null||context.cargo().isEmpty()?List.<Group>of():grouping.groups(context);
String reason=arrangement==null?"请先安排本车全部客户派送": String reason=arrangement==null?"请先安排本车全部客户派送":
!Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash()) !Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash())
?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排": ?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排":
outbounds.stream().anyMatch(o->!"WAIT_OUTBOUND".equals(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus()) outbounds.stream().anyMatch(o->!Set.of("WAIT_OUTBOUND","OUTBOUNDED").contains(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus())
|| d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null; || d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null;
return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(), return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(),
arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups), arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),
@@ -242,7 +328,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) { public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) {
if (departedAt==null) throw conflict("缺少实际发车时间"); if (departedAt==null) throw conflict("缺少实际发车时间");
var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason()); var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason());
var outbounds=repository.outbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false); var outbounds=activeOutbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false);
for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); } for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); }
for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt)); for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt));
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); } delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); }
@@ -256,7 +342,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
public NormalDeliveryFacts normalDeliveryFacts(Context context) { public NormalDeliveryFacts normalDeliveryFacts(Context context) {
var saved=repository.arrangement(context.dispatchId(),false); var saved=repository.arrangement(context.dispatchId(),false);
var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class);
var outbounds=repository.outbounds(context.dispatchId(),false); var outbounds=activeOutbounds(context.dispatchId(),false);
var actual=new ArrayList<NormalDeliveryFact>(); var actual=new ArrayList<NormalDeliveryFact>();
for (var delivery:repository.deliveries(context.dispatchId(),false)) { for (var delivery:repository.deliveries(context.dispatchId(),false)) {
var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId()) var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId())
@@ -311,7 +397,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId()); var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId());
var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey()))) var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey())))
.map(g->deliveries.get(g.key())).toList(); .map(g->deliveries.get(g.key())).toList();
repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now)); repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now),"OUTBOUNDED".equals(outbound.getStatus()));
for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId()); for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId());
history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo()); history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo());
history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);} history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);}
@@ -397,3 +483,4 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
/** 任一客户失败由外层事务回滚全部单据和费用。 */ /** 任一客户失败由外层事务回滚全部单据和费用。 */
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); }
} }
@@ -34,10 +34,11 @@ public class CargoDiscardController {
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode)); return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
} }
/** 从可信登录身份保存申请人姓名快照。 */
@PostMapping @PostMapping
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) { @RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); return ApiResponse.success(service.create(scope(user, customerId), operator(user), org.springframework.util.StringUtils.hasText(user.displayName()) ? user.displayName() : user.username(), request));
} }
@GetMapping @GetMapping
@@ -24,10 +24,11 @@ public class MerchantCargoDiscardController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); } @RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
/** 从可信登录身份保存申请人姓名快照。 */
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')")
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), org.springframework.util.StringUtils.hasText(user.displayName()) ? user.displayName() : user.username(), request)); }
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@@ -17,7 +17,10 @@ public final class CargoDiscardRequests {
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items, public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {} @Size(max = 1000) String remark, @Positive Integer requestedPackages) {
/** 逐箱申请从明细计数,无箱唛申请另传整单目标箱数。 */
public Create(List<Item> items, String idempotencyKey, String remark) { this(items, idempotencyKey, remark, null); }
}
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode, public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) { @Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
@@ -38,11 +38,16 @@ public final class CargoDiscardResponses {
String overseasWarehouseId, String overseasWarehouseName, String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status, int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt, String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt,
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages) {} List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages, String createdByName) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode, public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
String quantityUnit, Integer actualDiscardPackages, String pdaStatus) { String quantityUnit, Integer actualDiscardPackages, String pdaStatus,
List<String> actualCartonMarks, String palletNo) {
/** 保留历史详情调用,未记录的现场箱唛和托盘不补造。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks, String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, quantityUnit, actualDiscardPackages, pdaStatus, List.of(), null);
}
/** 无来源事实的调用不猜测数量单位或实际完成数。 */ /** 无来源事实的调用不猜测数量单位或实际完成数。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) { public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null); this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null);
@@ -26,6 +26,7 @@ public class CargoDiscardApplication {
private String idempotencyKey; private String idempotencyKey;
private String requestHash; private String requestHash;
private String createdBy; private String createdBy;
private String createdByName;
private String reviewedBy; private String reviewedBy;
private String reviewRemark; private String reviewRemark;
private LocalDateTime customerConfirmedAt; private LocalDateTime customerConfirmedAt;
@@ -18,6 +18,7 @@ public class CargoDiscardItem {
private Long returnCartonId; private Long returnCartonId;
private Long inventoryPositionId; private Long inventoryPositionId;
private String cartonMarksJson; private String cartonMarksJson;
private String actualCartonMarksJson;
private Boolean cartonScanRequired; private Boolean cartonScanRequired;
private String inboundCode; private String inboundCode;
private Long profileId; private Long profileId;
@@ -12,6 +12,29 @@ import java.util.List;
@Mapper @Mapper
public interface CargoDiscardInventoryMapper { public interface CargoDiscardInventoryMapper {
record BulkSource(String sourceInboundId, String inboundCode, int packageCount) { }
/** 仅完整单SKU的FBA数量实收可回查原箱;多SKU/分批或逐箱库存仍走精确关联。 */
@Select("""
SELECT o.source_inbound_id,o.inbound_code,o.package_count FROM ll_inventory_position p
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id
JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=b.customer_id
WHERE p.id=#{positionId} AND p.customer_id=#{customerId}
AND o.destination_type='FBA' AND o.order_type='FIRST_LEG'
AND o.source_inbound_id=o.root_inbound_id
AND p.batch_no=o.inbound_order_no AND p.overseas_warehouse_id=o.overseas_warehouse_id
AND (SELECT COUNT(*) FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id)=1
AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id
AND d.sku_code=b.sku_code AND d.package_count=o.package_count)
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_carton c WHERE c.order_id=o.id AND c.customer_id=o.customer_id)
AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.position_id=p.id AND l.customer_id=p.customer_id
AND l.business_type='STOCK_IN' AND l.carton_mark IS NULL AND l.delta_packages>0)
""")
BulkSource bulkSource(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 已实际废弃的原箱不得因仍属于头程有效批次而再次扫描。 */
@Select("SELECT actual_carton_marks_json FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND inbound_code=#{inboundCode} AND actual_discard_packages>0 AND actual_carton_marks_json IS NOT NULL")
List<String> discardedMarkSnapshots(@Param("customerId") String customerId, @Param("inboundCode") String inboundCode);
// 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。 // 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。
String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " + String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " +
@@ -84,6 +107,25 @@ public interface CargoDiscardInventoryMapper {
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " + @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") "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); List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 现场箱唛须能溯源到该客户该仓位的实收流水或原入仓单,不能凭数量放行。 */
@Select("""
SELECT l.carton_mark FROM ll_inventory_ledger l
JOIN ll_inventory_position p ON p.customer_id=l.customer_id AND p.id=l.position_id AND p.balance_id=l.balance_id
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id AND b.inbound_order_id=l.inbound_order_id
WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId}
AND l.carton_mark IS NOT NULL AND l.carton_mark<>''
GROUP BY l.carton_mark HAVING SUM(l.delta_packages)>0
UNION
SELECT DISTINCT c.carton_mark FROM ll_inventory_position p
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.received_at IS NOT NULL
AND c.status IN ('AVAILABLE','DISCARD_PENDING') AND c.carton_mark IS NOT NULL AND c.carton_mark<>''
AND (c.warehouse_pallet_id=p.pallet_id OR c.warehouse_pallet_id IS NULL)
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
""")
List<String> traceableCartonMarks(@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," + @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," + "o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
@@ -0,0 +1,80 @@
package com.baiyue.lastleg.discard.mapper;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 已确认待发换标箱的废弃归属账本;取消及扣减均保留原换标事实。 */
@Mapper
public interface CargoDiscardRelabelMapper {
/** 与排车使用相同原申请行锁,锁顺序由调用方统一排列。 */
@Select("SELECT relabel_no FROM ll_relabel_application WHERE relabel_no=#{no} FOR UPDATE")
String lock(@Param("no") String no);
String ELIGIBLE = "a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' "
+ "AND a.customer_confirmed_at IS NOT NULL AND COALESCE(a.outbound_order_no,'')='' "
+ "AND i.return_carton_id IS NULL AND i.notified_package_count=1 "
+ "AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id "
+ "AND dc.carton_id=i.id AND dc.source_type IN ('RELABEL','RELABEL_DISCARDED')) ";
/** 已确认箱必须仍在实际冻结仓位;当前轮次绑定由业务端口继续复核。 */
@Select("SELECT i.* 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.source_inventory_position_id=#{positionId} AND " + ELIGIBLE + " ORDER BY i.id")
List<RelabelItem> cartons(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 通过原入仓单及客户/系统箱唛定位唯一转运实收箱,废弃扫码始终使用系统箱唛。 */
@Select("SELECT DISTINCT c.carton_mark FROM ll_relabel_item i "
+ "JOIN ll_warehouse_transfer_order t ON t.customer_id=i.customer_id AND t.inbound_order_id=i.source_inbound_order_id "
+ "JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id "
+ "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) "
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id} ORDER BY c.carton_mark")
List<String> systemCartonMarks(@Param("customerId") String customerId, @Param("id") Long id);
/** 箱级交接使用锁定读复核最新归属,不能复用提交前的一致性读快照。 */
@Select("SELECT i.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.id=#{id} AND " + ELIGIBLE + " FOR UPDATE")
Long lockEligible(@Param("customerId") String customerId, @Param("id") Long id);
/** 增补仍有已确认换标冻结实箱的候选仓位,不开放其他业务冻结库存。 */
@Select("<script>SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "
+ "WHERE p.customer_id=#{customerId} AND p.hold_packages&gt;0 AND b.hold_packages&gt;0 "
+ "AND EXISTS(SELECT 1 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=p.customer_id AND i.source_inventory_position_id=p.id AND a.overseas_warehouse_id=p.overseas_warehouse_id AND " + ELIGIBLE + ") "
+ "AND NOT EXISTS(SELECT 1 FROM ll_warehouse_transfer_inventory_allocation t WHERE t.customer_id=p.customer_id AND t.inventory_position_id=p.id AND t.status='RESERVED') "
+ "<if test='keyword != null and keyword != &quot;&quot;'>AND b.inbound_code LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY p.id</script>")
List<InventoryPosition> positions(@Param("customerId") String customerId, @Param("keyword") String keyword);
/** 排车与废弃统一先锁原换标申请,再锁库存仓位。 */
@Select("<script>SELECT DISTINCT a.relabel_no FROM ll_relabel_application a JOIN ll_relabel_item i ON i.application_id=a.id AND i.customer_id=a.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.source_inventory_position_id IN "
+ "<foreach collection='positions' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY a.relabel_no</script>")
List<String> applications(@Param("customerId") String customerId, @Param("positions") List<Long> positions);
/** 原申请业务号用于交接及取消恢复冻结。 */
@Select("SELECT a.relabel_no 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.id=#{id}")
String relabelNo(@Param("customerId") String customerId, @Param("id") Long id);
/** 识别已转交换标的旧转运箱,避免同一箱在原托盘再次出现。 */
@Select("SELECT DISTINCT c.id 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 "
+ "JOIN ll_relabel_item i ON i.customer_id=c.customer_id AND i.source_inbound_order_id=t.inbound_order_id "
+ "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) "
+ "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE c.customer_id=#{customerId} AND a.status NOT IN ('CANCELLED','REJECTED')")
List<Long> transferredCartons(@Param("customerId") String customerId);
/** 使用原状态条件将本次箱级占用改为取消或已实际废弃。 */
@Update("UPDATE ll_cargo_discard_carton SET source_type=#{target} WHERE customer_id=#{customerId} AND carton_id=#{id} AND source_type='RELABEL'")
int finish(@Param("customerId") String customerId, @Param("id") Long id, @Param("target") String target);
/** 有待废弃箱时暂停排车,结算后剩余箱恢复;全部实际废弃后终止履约。 */
@Update("UPDATE ll_relabel_application SET fulfillment_status=CASE "
+ "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.carton_id=i.id AND dc.customer_id=i.customer_id "
+ "AND dc.source_type='RELABEL' WHERE i.application_id=ll_relabel_application.id) THEN 'DISCARD_PENDING' "
+ "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i WHERE i.application_id=ll_relabel_application.id AND NOT EXISTS "
+ "(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED')) "
+ "THEN 'WAIT_PRE_DISPATCH' ELSE 'DISCARDED' END,updated_at=NOW(3) "
+ "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND status='READY_FOR_OUTBOUND' "
+ "AND fulfillment_status IN ('WAIT_PRE_DISPATCH','DISCARD_PENDING','DISCARDED') AND COALESCE(outbound_order_no,'')=''")
int refresh(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
}
@@ -6,6 +6,17 @@ import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */ /** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities { public final class CargoDiscardQuantities {
/** 明细保留候选托盘预留容量,整单总箱数小于容量时按客户目标跨托盘分配。 */
public static boolean quantityPool(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
return application.getTotalPackages() != null && !lines.isEmpty()
&& lines.stream().allMatch(i -> "INVENTORY".equals(i.getSourceType()) && i.getInventoryPositionId() != null
&& !Boolean.TRUE.equals(i.getCartonScanRequired()) && (i.getCartonMarksJson() == null || "[]".equals(i.getCartonMarksJson())))
&& application.getTotalPackages() < boxes(lines, false);
}
/** 客户申请箱数与候选库存容量分别统计。 */
public static int requested(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
return lines.isEmpty() || quantityPool(application, lines) ? application.getTotalPackages() : boxes(lines, false);
}
/** 数量规则不需要实例化。 */ /** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { } private CargoDiscardQuantities() { }
@@ -13,7 +13,11 @@ public final class PdaCargoDiscardDtos {
public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) { public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
public Scan(String palletNo) { this(palletNo, null); } public Scan(String palletNo) { this(palletNo, null); }
} }
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages,
List<@NotBlank @Size(max = 128) String> scannedCartonMarks) {
/** 已关联箱唛的旧调用继续使用整托扫码清单。 */
public ActualLine(Long itemId, int actualPackages) { this(itemId, actualPackages, null); }
}
public record Confirm(@NotEmpty List<@Valid ActualLine> lines, public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds, @NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons, List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
@@ -26,8 +30,9 @@ public final class PdaCargoDiscardDtos {
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */ /** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status, String quantityUnit, Integer actualPackages, String status, String quantityUnit,
boolean actualQuantityEditable) { } boolean actualQuantityEditable, List<String> allowedCartonMarks) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo, String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { } String scanMode, List<String> cartonMarks, String remark,
Integer requestedPackages, Integer remainingPackages) { }
} }
@@ -69,18 +69,35 @@ public class PdaCargoDiscardService {
if (!line.actualQuantityEditable() && actual != line.plannedPackages()) if (!line.actualQuantityEditable() && actual != line.plannedPackages())
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
} }
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) { // 有真实箱号必须逐箱核对,兼容字段不能绕过扫码校验。
if ("CARTON".equals(task.scanMode())) {
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks(); var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks()))) if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
throw conflict("必须扫描全部箱唛,或选择一键全部废弃"); throw conflict("必须扫描本次废弃的全部箱号");
} }
List<CargoDiscardItem> palletItems = new ArrayList<>(); List<CargoDiscardItem> palletItems = new ArrayList<>();
// 无箱唛关联的库存按现场逐箱扫码计数,箱唛仅记录实弃事实,不冒充原箱级关联。
var observed = new HashSet<String>();
var otherMarks = repository.items(application.getCustomerId(), application.getId()).stream()
.filter(i -> "INVENTORY".equals(i.getSourceType()))
.filter(i -> !physicalCode(application, i).equals(task.palletNo()))
.flatMap(i -> stockService.actualCartonMarks(i).stream()).collect(Collectors.toSet());
for (PdaCargoDiscardDtos.Sku line : task.skus()) { for (PdaCargoDiscardDtos.Sku line : task.skus()) {
CargoDiscardItem item = repository.item(line.itemId()); CargoDiscardItem item = repository.item(line.itemId());
if (item == null || !application.getCustomerId().equals(item.getCustomerId()) if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId()) || !Objects.equals(application.getId(), item.getApplicationId())
|| !physicalCode(application, item).equals(task.palletNo())) || !physicalCode(application, item).equals(task.palletNo()))
throw conflict("废弃明细不属于当前申请或托盘"); throw conflict("废弃明细不属于当前申请或托盘");
if (line.actualQuantityEditable()) {
var input = request.lines().stream().filter(i -> i.itemId().equals(line.itemId())).findFirst().orElseThrow();
var scanned = input.scannedCartonMarks() == null ? List.<String>of() : input.scannedCartonMarks();
if (scanned.size() != input.actualPackages() || scanned.stream().anyMatch(mark -> mark == null || mark.isBlank()
|| mark.length() > 128 || !mark.equals(mark.trim()) || !observed.add(mark) || otherMarks.contains(mark)))
throw conflict("实际废弃箱数必须与逐箱扫码一致,箱唛不能重复或跨托盘重复废弃");
if (!"COMPLETED".equals(item.getPdaStatus())) stockService.validateActualCartonMarks(application.getCustomerId(), item, scanned);
if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned)))
throw conflict("已完成托盘的实际废弃箱唛不能修改");
}
if ("COMPLETED".equals(item.getPdaStatus()) if ("COMPLETED".equals(item.getPdaStatus())
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) && !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
throw conflict("已完成托盘的实际数量不能修改"); throw conflict("已完成托盘的实际数量不能修改");
@@ -91,10 +108,23 @@ public class PdaCargoDiscardService {
|| !application.getCustomerId().equals(item.getCustomerId()) || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId()))) || !Objects.equals(application.getId(), item.getApplicationId())))
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
photoService.confirm(application, task.palletNo(), request.photoFileIds(), // 已完成托盘在整单未结束时也只核对原结果,不能再次计入数量池或扣减库存。
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) {
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。 if (!terminal) photoService.confirm(application, task.palletNo(), request.photoFileIds(), true);
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; return task;
}
var allItems = repository.items(application.getCustomerId(), application.getId());
boolean quantityPool = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.quantityPool(application, allItems);
if (quantityPool) {
int done = allItems.stream().filter(i -> "COMPLETED".equals(i.getPdaStatus()))
.mapToInt(i -> Objects.requireNonNullElse(i.getActualDiscardPackages(), 0)).sum();
int current = actuals.values().stream().mapToInt(Integer::intValue).sum();
int availableLater = allItems.stream().filter(i -> !"COMPLETED".equals(i.getPdaStatus()) && !actuals.containsKey(i.getId()))
.mapToInt(CargoDiscardItem::getDiscardPackages).sum();
if ((long)done + current > application.getTotalPackages()) throw conflict("累计废弃箱数不能超过客户申请箱数,请刷新核对剩余数量");
if ((long)done + current + availableLater < application.getTotalPackages()) throw conflict("剩余托盘数量不足以完成申请,请核对本托盘实际废弃箱数");
}
photoService.confirm(application, task.palletNo(), request.photoFileIds(), false);
if (terminal) throw conflict("废弃终态不能执行未完成托盘"); if (terminal) throw conflict("废弃终态不能执行未完成托盘");
storageRent.consume(String.valueOf(identity.overseasWarehouseId()),new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest( storageRent.consume(String.valueOf(identity.overseasWarehouseId()),new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
"DISCARD",discardNo,null,null,null,null,palletNo,request.idempotencyKey(),null,null,null, "DISCARD",discardNo,null,null,null,null,palletNo,request.idempotencyKey(),null,null,null,
@@ -113,12 +143,25 @@ public class PdaCargoDiscardService {
adjustments.discardPalletItem(application.getCustomerId(), application, item, links, adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
actuals.get(item.getId()), identity.operatorKey(), now); actuals.get(item.getId()), identity.operatorKey(), now);
} }
var actualMarks = task.skus().stream().filter(s -> s.itemId().equals(item.getId())).findFirst().orElseThrow().actualQuantityEditable()
? request.lines().stream().filter(i -> i.itemId().equals(item.getId())).findFirst().orElseThrow().scannedCartonMarks()
: cartonMarks(application, item);
stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item); item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
} }
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item) task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct() .filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
// 达到客户申请总数后无需逐个空扫其余托盘,仅释放未使用预留,不扣库存、不伪造照片。
allItems = repository.items(application.getCustomerId(), application.getId());
if (quantityPool && com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true) == application.getTotalPackages()) {
for (var unused : allItems) if (!"COMPLETED".equals(unused.getPdaStatus())) {
stockService.discard(application.getCustomerId(), identity.operatorKey(), application, unused, 0);
unused.setActualDiscardPackages(0); unused.setPdaStatus("COMPLETED");
unused.setPdaOperatorId(identity.operatorKey()); unused.setPdaCompletedAt(now); repository.saveItem(unused);
}
}
if (repository.items(application.getCustomerId(), application.getId()).stream() if (repository.items(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes( int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
@@ -176,16 +219,20 @@ public class PdaCargoDiscardService {
CargoDiscardItem first = entry.getValue().get(0); CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(), 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(), i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList(); isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i),
"INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i) ? stockService.allowedCartonMarks(application.getCustomerId(), i) : cartonMarks(application, i))).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; 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(); var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 // 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream() boolean cartonScan = !marks.isEmpty() || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired())); .anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛"); if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(), return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(), entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks); status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks,
application.getRemark(), com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, allItems),
application.getTotalPackages() == null ? null : Math.max(0, application.getTotalPackages()
- com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true)));
}).toList(); }).toList();
} }
/** 相关托盘只从客户库存事实读取,不从请求推断。 */ /** 相关托盘只从客户库存事实读取,不从请求推断。 */
@@ -15,6 +15,6 @@ public class CargoDiscardQuantityRepository {
public int boxes(CargoDiscardApplication application) { public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId()); var lines = items.listApplication(application.getCustomerId(), application.getId());
boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus()); boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed); return lines.isEmpty() ? application.getTotalPackages() : completed ? CargoDiscardQuantities.boxes(lines, true) : CargoDiscardQuantities.requested(application, lines);
} }
} }
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 换标废弃事实仅经本库 Mapper 读取和原状态条件更新。 */
@Repository
public class CargoDiscardRelabelRepository {
private final CargoDiscardRelabelMapper mapper;
public CargoDiscardRelabelRepository(CargoDiscardRelabelMapper mapper) { this.mapper = mapper; }
/** 按原申请业务号取得正式行锁。 */
public String lock(String no) { return mapper.lock(no); }
/** 复验最新箱级状态,拒绝并发废弃或已经排车的旧页面。 */
public boolean lockEligible(String customer, Long id) { return mapper.lockEligible(customer, id) != null; }
/** 已确认待发实箱。 */
public List<RelabelItem> cartons(String customer, Long position) { return mapper.cartons(customer, position); }
/** 读取换标箱对应的原系统箱唛,避免以 FBA 箱唛替代仓内扫码身份。 */
public List<String> systemCartonMarks(String customer, Long item) { return mapper.systemCartonMarks(customer, item); }
/** 已确认冻结仓位。 */
public List<InventoryPosition> positions(String customer, String keyword) { return mapper.positions(customer, keyword); }
/** 原申请锁集合。 */
public List<String> applications(String customer, List<Long> positions) { return positions.isEmpty() ? List.of() : mapper.applications(customer, positions); }
/** 换标业务归属。 */
public String relabelNo(String customer, Long item) { return mapper.relabelNo(customer, item); }
/** 原托盘旧箱记录。 */
public List<Long> transferredCartons(String customer) { return mapper.transferredCartons(customer); }
/** 结束箱级独占。 */
public int finish(String customer, Long item, String target) { return mapper.finish(customer, item, target); }
/** 刷新整单剩余履约资格。 */
public int refresh(String customer, String no) { return mapper.refresh(customer, no); }
}
@@ -12,6 +12,10 @@ import java.util.List;
/** 封装普通库存废弃的客户隔离读取、锁箱和申请快照保存。 */ /** 封装普通库存废弃的客户隔离读取、锁箱和申请快照保存。 */
@Repository @Repository
public class CargoDiscardStockRepository { public class CargoDiscardStockRepository {
/** 读取允许按原有效批次溯源的数量入库来源。 */
public CargoDiscardInventoryMapper.BulkSource bulkSource(String customer, Long position) { return stock.bulkSource(customer, position); }
/** 返回已实际废弃的箱唛快照,防止后续数量申请复扫。 */
public List<String> discardedMarkSnapshots(String customer, String inboundCode) { return stock.discardedMarkSnapshots(customer, inboundCode); }
@Resource private CargoDiscardInventoryMapper stock; @Resource private CargoDiscardInventoryMapper stock;
@Resource private InventoryPositionMapper positions; @Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances; @Resource private InventoryBalanceMapper balances;
@@ -32,6 +36,8 @@ public class CargoDiscardStockRepository {
public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; } public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; }
/** 读取散箱实收流水中的箱码快照。 */ /** 读取散箱实收流水中的箱码快照。 */
public List<String> looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); } public List<String> looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); }
/** 读取本仓位原入库单与实收流水允许扫描的箱唛。 */
public List<String> traceableMarks(String customer, Long position) { return stock.traceableCartonMarks(customer, position); }
/** 保存废弃申请的库存来源明细。 */ /** 保存废弃申请的库存来源明细。 */
public void saveItem(CargoDiscardItem item) { items.insert(item); } public void saveItem(CargoDiscardItem item) { items.insert(item); }
/** 保存申请所选实物箱快照。 */ /** 保存申请所选实物箱快照。 */
@@ -51,7 +51,7 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND")); outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false)); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application)); outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO); outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
@@ -67,7 +67,8 @@ public class CargoDiscardOutboundService {
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId()); var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配"); if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
palletNo = position.getPalletNo(); palletNo = position.getPalletNo();
cartonMarks = String.join(",", stockService.cartonMarks(line)); cartonMarks = String.join(",", line.getActualCartonMarksJson() == null
? stockService.cartonMarks(line) : stockService.actualCartonMarks(line));
} else if (returned) { } else if (returned) {
var carton = returnCartons.selectById(line.getReturnCartonId()); var carton = returnCartons.selectById(line.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId()) if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
@@ -134,7 +135,8 @@ public class CargoDiscardOutboundService {
}; };
outbound.setStatus(status); outbound.setStatus(status);
outbound.setLxCodes(null); outbound.setLxCodes(null);
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status))); outbound.setPackageCount("OUTBOUNDED".equals(status) ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true)
: com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
} }
/** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */ /** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */
@@ -0,0 +1,123 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow;
import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.*;
/** 已换标实箱在原换标冻结与废弃冻结之间原子交接,保留原箱历史。 */
@Service
public class CargoDiscardRelabelStockService {
@Resource private CargoDiscardRelabelRepository repository;
@Resource private RelabelDispatchSourcePort sources;
@Resource private InventoryCommandService commands;
@Resource private ObjectMapper json;
/** 仅增补有待发换标库存的位置。 */
public List<InventoryPosition> positions(String customer, String keyword) { return repository.positions(customer, keyword); }
/** 从当前轮次实托绑定核对箱级贡献,换标完成但未绑定的箱不能猜测位置。 */
public List<CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
var items = repository.cartons(customer, position.getId());
if (items.isEmpty() || position.getPalletId() == null || position.getPalletId() <= 0) return List.of();
var snapshot = sources.snapshot(position.getOverseasWarehouseId(), List.of(position.getPalletId()));
if (!snapshot.complete()) return List.of();
var bound = snapshot.sources().stream().filter(s -> customer.equals(s.customerId())
&& Objects.equals(position.getId(), s.inventoryPositionId()) && s.boundPackageCount() == 1)
.map(RelabelDispatchSourcePort.BoundSource::itemId).collect(java.util.stream.Collectors.toSet());
var result = new ArrayList<CargoDiscardStockCartonRow>();
for (var item : items) {
if (!bound.contains(item.getId())) continue;
var box = new CargoDiscardStockCartonRow(); box.setId(item.getId()); box.setSourceType("RELABEL");
// 系统箱唛是仓内实箱身份;换标仅更新 FBA 箱唛和商品标签,不替换废弃扫码身份。
var systemMarks = repository.systemCartonMarks(customer, item.getId());
if (systemMarks.size() > 1) throw conflict("换标箱对应多个系统箱唛,请先核对原转运箱记录");
box.setCartonMark(systemMarks.isEmpty() ? item.getCartonMark() : systemMarks.get(0));
box.setSkuCode(item.getNewSkuCode() == null || item.getNewSkuCode().isBlank() ? item.getSkuCode() : item.getNewSkuCode());
try {
var values = json.readTree(Objects.toString(item.getSkuRelabelItemsJson(), "[]"));
if (values.isArray() && !values.isEmpty()) {
var contents = json.createArrayNode();
for (var value : values) {
var content = contents.addObject();
String code = value.path("newSkuCode").asText();
content.put("skuCode", code.isBlank() ? value.path("skuCode").asText() : code);
content.put("quantity", value.path("quantity").asLong());
}
box.setSkuItemsJson(json.writeValueAsString(contents));
}
} catch (Exception invalid) { throw conflict("换标箱内 SKU 事实无法读取"); }
result.add(box);
}
return result.size() <= number(position.getHoldPackages()) ? result : List.of();
}
/** 排除已转交换标处理的旧转运箱,不能同时宣称原托盘和新托盘都存有该箱。 */
public Set<Long> transferred(String customer) { return new HashSet<>(repository.transferredCartons(customer)); }
/** 保存废弃选箱账本前以最新锁定读认领实箱。 */
public int claim(String customer, Long item) { return repository.lockEligible(customer, item) ? 1 : 0; }
/** 与排车共用按业务号排序的原申请锁,之后再取得库存正式锁。 */
public void lock(String customer, List<Long> positions) {
var applications = repository.applications(customer, positions);
applications.stream().distinct().sorted().forEach(no -> {
if (repository.lock(no) == null) throw conflict("换标原申请不存在");
});
}
/** 先归还原换标冻结,紧接着由调用方以废弃周期重新冻结,同事务不暴露可用窗口。 */
public void handoff(String customer, String operator, CargoDiscardApplication app, CargoDiscardItem line,
List<CargoDiscardStockCartonRow> selected) {
for (var box : selected) if ("RELABEL".equals(box.getSourceType())) {
String no = requireNo(customer, box.getId());
commands.release(customer, operator, move(line, app, box.getId(), no, "HANDOFF"));
}
}
/** 箱级占用已持久化后暂停原换标出库;同一混标箱多SKU行只需刷新一次。 */
public void synchronize(List<CargoDiscardCarton> links, String customer) {
var ids = links.stream().filter(link -> "RELABEL".equals(link.getSourceType()))
.map(CargoDiscardCarton::getCartonId).distinct().toList();
var no = new TreeSet<String>(); ids.forEach(id -> no.add(requireNo(customer, id)));
for (String relabelNo : no) if (repository.refresh(customer, relabelNo) != 1)
throw conflict("换标单已进入其他出库流程");
}
/** 实际废弃或撤销记录保留箱级结果;整单仅依据剩余箱恢复资格。 */
public int finish(String customer, Long item, boolean discarded) {
String no = requireNo(customer, item);
int changed = repository.finish(customer, item, discarded ? "RELABEL_DISCARDED" : "RELABEL_RELEASED");
if (changed == 1 && repository.refresh(customer, no) != 1) throw conflict("换标废弃归属发生变化");
return changed;
}
/** 撤销后恢复原换标 HOLD,不能把已换标待发箱释放为普通可用箱。 */
public void restore(String customer, CargoDiscardApplication app, CargoDiscardItem line, List<CargoDiscardCarton> links) {
for (var link : links) if (Objects.equals(line.getId(), link.getItemId()) && "RELABEL".equals(link.getSourceType()))
commands.hold(customer, app.getCreatedBy(), move(line, app, link.getCartonId(), requireNo(customer, link.getCartonId()), "RESTORE"));
}
/** 每次废弃申请独立幂等,允许取消后重新申请而不复用旧释放结果。 */
private InventoryCommandRequests.Move move(CargoDiscardItem line, CargoDiscardApplication app, Long item, String no, String action) {
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), 1, no, null, "RELABEL:" + no,
"DISCARD_RELABEL:" + app.getId() + ":" + item + ":" + action, "换标库存废弃交接 " + action);
}
/** 原申请必须可追溯。 */
private String requireNo(String customer, Long item) {
String no = repository.relabelNo(customer, item);
if (no == null) throw conflict("换标箱原申请不存在");
return no;
}
private long number(Long value) { return value == null ? 0 : value; }
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -84,16 +84,10 @@ public class CargoDiscardService {
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}") @Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
private BigDecimal feePerPackage = new BigDecimal("5.00"); private BigDecimal feePerPackage = new BigDecimal("5.00");
/** 仅提供允许废弃的普通库存和退货箱候选。 */
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) { public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode)); // 货物废弃仅开放普通库存和退货库存,不提供一件代发订单候选。
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>(); List<CargoDiscardResponses.Candidate> result = new java.util.ArrayList<>();
for (CargoDiscardCandidateRow row : rows) {
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
value.add(row);
}
List<CargoDiscardResponses.Candidate> result = grouped.values().stream()
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
result.addAll(stockService.candidates(customerId, trim(inboundCode))); result.addAll(stockService.candidates(customerId, trim(inboundCode)));
String keyword = trim(inboundCode); String keyword = trim(inboundCode);
if (returnCartons == null || returnCartonItems == null) return result; if (returnCartons == null || returnCartonItems == null) return result;
@@ -141,12 +135,16 @@ public class CargoDiscardService {
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃"); supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
} }
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */ /** 申请人姓名由认证身份提供快照,不采信请求字段。 */
@Transactional @Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId, public CargoDiscardResponses.Detail create(String customerId, String operatorId, String operatorName,
CargoDiscardRequests.Create request) { CargoDiscardRequests.Create request) {
if (request.items().stream().anyMatch(item -> item.orderId() != null))
throw badRequest("货物废弃不支持一件代发订单");
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
if (request.requestedPackages() != null && !StringUtils.hasText(request.remark()))
throw badRequest("无箱唛数量废弃必须填写备注");
CargoDiscardApplication replay = applications.findKey(customerId, key); CargoDiscardApplication replay = applications.findKey(customerId, key);
if (replay != null) { if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) { if (!requestHash.equals(replay.getRequestHash())) {
@@ -219,6 +217,13 @@ public class CargoDiscardService {
} }
total = Math.addExact(total, returnCartonCache.size()); total = Math.addExact(total, returnCartonCache.size());
total = Math.addExact(total, requestedBoxes.size()); total = Math.addExact(total, requestedBoxes.size());
// 客户只指定总箱数,托盘候选预留后由仓库分配实际数量。
if (request.requestedPackages() != null) {
if (request.requestedPackages() <= 0 || request.requestedPackages() > total
|| request.items().stream().anyMatch(i -> !stockService.quantitySelectionAllowed(customerId, i)))
throw badRequest("按数量废弃仅支持有托盘且无箱唛关联的库存,申请箱数不能超过可用库存");
total = request.requestedPackages();
}
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication(); CargoDiscardApplication application = new CargoDiscardApplication();
@@ -234,7 +239,7 @@ public class CargoDiscardService {
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total))); application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW); application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key); application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
application.setRequestHash(requestHash); application.setCreatedBy(operatorId); application.setRequestHash(requestHash); application.setCreatedBy(operatorId); application.setCreatedByName(operatorName);
application.setCreatedAt(now); application.setUpdatedAt(now); application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application); applications.insert(application);
@@ -283,13 +288,15 @@ public class CargoDiscardService {
return statisticsService.statistics(customerId, query); return statisticsService.statistics(customerId, query);
} }
/** 详情返回各来源实际完成箱唛及托盘,退货沿用实收箱事实。 */
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) { public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo); CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found"); if (application == null) throw notFound("Cargo discard application not found");
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream() List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value), value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus())) value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus(),
actualMarks(customerId, application, value), detailPallet(customerId, value)))
.toList(); .toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream() List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -298,6 +305,25 @@ public class CargoDiscardService {
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews, photoService.list(application, null, true)); application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews, photoService.list(application, null, true));
} }
/** 实际箱唛优先取作业快照,退货已完成明细可沿已核验的所选箱恢复。 */
private List<String> actualMarks(String customer, CargoDiscardApplication application, CargoDiscardItem item) {
var marks = stockService.actualCartonMarks(item);
if (!marks.isEmpty() || !"COMPLETED".equals(item.getPdaStatus()) || item.getActualDiscardPackages() == null || item.getActualDiscardPackages() <= 0) return marks;
return selectedMarks(customer, application, item);
}
/** 托盘只读对应来源的真实实收记录,散箱保留明确标记。 */
private String detailPallet(String customer, CargoDiscardItem item) {
if (item.getInventoryPositionId() != null) return stockService.position(customer, item.getInventoryPositionId()).getPalletNo();
if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !customer.equals(carton.getCustomerId())) return null;
return StringUtils.hasText(carton.getPalletCode()) ? carton.getPalletCode() : "散箱";
}
var profile = profiles.selectById(item.getProfileId());
return profile != null && customer.equals(profile.getCustomerId()) ? profile.getPalletNo() : null;
}
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String discardNo, long fileId) { public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String discardNo, long fileId) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo); CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found"); if (application == null) throw notFound("Cargo discard application not found");
@@ -522,13 +548,13 @@ public class CargoDiscardService {
int boxes = quantities.boxes(value); int boxes = quantities.boxes(value);
var lines = items.listApplication(value.getCustomerId(), value.getId()); var lines = items.listApplication(value.getCustomerId(), value.getId());
var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList(); var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList();
int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false); int requested = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(value, lines);
Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null) Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null)
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null; ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes, 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.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(), skuCodes, skuCodes.size(), requested, actual); value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual, value.getCreatedByName());
} }
private void history(String customerId, Long applicationId, String from, String to, String operatorId, private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -25,7 +25,7 @@ public class CargoDiscardStatisticsService {
boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true; boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true;
for (var application : applications) { for (var application : applications) {
var lines = dimensions.items(application.getCustomerId(), application.getId()); var lines = dimensions.items(application.getCustomerId(), application.getId());
boxes += lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); boxes += CargoDiscardQuantities.requested(application, lines);
if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; } if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; }
var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList(); var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList();
if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); } if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); }
@@ -20,14 +20,20 @@ public class CargoDiscardStockService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
@Resource private InventoryCommandService commands; @Resource private InventoryCommandService commands;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private CargoDiscardRelabelStockService relabelStock;
@Resource private com.baiyue.lastleg.client.FirstlegCartonMarksClient firstlegCartonMarks;
@org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token:}") private String internalToken;
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */ /** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) { public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
return repository.candidates(customer, keyword).stream().map(position -> { var positions = new LinkedHashMap<Long, InventoryPosition>();
repository.candidates(customer, keyword).forEach(p -> positions.put(p.getId(), p));
relabelStock.positions(customer, keyword).forEach(p -> positions.put(p.getId(), p));
return positions.values().stream().filter(position -> !repository.dropshipPosition(customer, position.getId())).map(position -> {
var balance = repository.balance(position.getBalanceId()); var balance = repository.balance(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在"); if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); long available = available(customer, position, balance);
var cartons = repository.cartons(customer, position.getId()); var cartons = cartons(customer, position);
boolean complete = completeCartons(cartons, available); boolean complete = completeCartons(cartons, available);
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(), var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of(); contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
@@ -40,6 +46,15 @@ public class CargoDiscardStockService {
}).filter(c -> c.availablePackages() > 0).toList(); }).filter(c -> c.availablePackages() > 0).toList();
} }
/** 按数量选择仅适用于有托盘且没有完整逐箱关联的普通库存。 */
public boolean quantitySelectionAllowed(String customer, CargoDiscardRequests.Item request) {
if (request.inventoryPositionId() == null || request.cartonIds() != null) return false;
var position = position(customer, request.inventoryPositionId());
var balance = validate(customer, request);
return position.getPalletNo() != null && !position.getPalletNo().isBlank()
&& !completeCartons(cartons(customer, position), available(customer, position, balance));
}
/** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */ /** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) { public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
var position = position(customer, request.inventoryPositionId()); var position = position(customer, request.inventoryPositionId());
@@ -47,33 +62,38 @@ public class CargoDiscardStockService {
if (balance == null || !customer.equals(balance.getCustomerId()) if (balance == null || !customer.equals(balance.getCustomerId())
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId())) || !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
throw conflict("库存不属于当前客户或仓库"); throw conflict("库存不属于当前客户或仓库");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); long available = available(customer, position, balance);
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足"); if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
if (request.cartonIds() != null) { if (request.cartonIds() != null) {
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size()) if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
throw conflict("废弃箱子数量与所选箱子不一致或重复"); throw conflict("废弃箱子数量与所选箱子不一致或重复");
var cartons = repository.cartons(customer, position.getId()); var cartons = cartons(customer, position);
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet()); var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选"); if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子"); if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
} else { } else {
if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子");
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中");
} }
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留"); if (repository.dropshipPosition(customer, position.getId())) throw conflict("货物废弃不支持一件代发库存");
return balance; return balance;
} }
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
relabelStock.lock(customer, List.of(request.inventoryPositionId()));
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
var balance = validate(customer, request); var balance = validate(customer, request);
reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>()); boolean relabelReserved = reserveValidated(customer, operator, application, request, at, balance,
cartons(customer, position(customer, request.inventoryPositionId())), new HashSet<>(), new HashSet<>());
if (relabelReserved) relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer);
} }
/** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */ /** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) { public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) {
var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList(); var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList();
relabelStock.lock(customer, stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().toList());
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> { stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在"); if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
}); });
@@ -82,7 +102,7 @@ public class CargoDiscardStockService {
var selectedSkus = new HashMap<String, Set<String>>(); var selectedSkus = new HashMap<String, Set<String>>();
for (var request : stocks) { for (var request : stocks) {
balances.put(request.inventoryPositionId(), validate(customer, request)); balances.put(request.inventoryPositionId(), validate(customer, request));
var facts = repository.cartons(customer, request.inventoryPositionId()); var facts = cartons(customer, position(customer, request.inventoryPositionId()));
cartons.put(request.inventoryPositionId(), facts); cartons.put(request.inventoryPositionId(), facts);
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId())) for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim()); selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
@@ -95,12 +115,17 @@ public class CargoDiscardStockService {
} }
var lockedBoxes = new HashSet<String>(); var lockedBoxes = new HashSet<String>();
var heldBoxes = new HashSet<String>(); var heldBoxes = new HashSet<String>();
for (var request : stocks) reserveValidated(customer, operator, application, request, at, boolean relabelReserved = false;
balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes); for (var request : stocks) {
if (reserveValidated(customer, operator, application, request, at,
balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes)) relabelReserved = true;
}
// 全部仓位认领和预留完成后统一暂停原换标出库,避免后续行被本事务提前改成不可选状态。
if (relabelReserved) relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer);
} }
/** 使用本事务变更箱状态前的完整事实,为每个 SKU 分别预留并保存相同箱唛快照。 */ /** 使用变更箱状态前的完整事实保存各 SKU 快照,返回本行是否新增换标箱预留。 */
private void reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at, private boolean reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at,
InventoryBalance balance, List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> availableCartons, Set<String> lockedBoxes, Set<String> heldBoxes) { InventoryBalance balance, List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> availableCartons, Set<String> lockedBoxes, Set<String> heldBoxes) {
var position = position(customer, request.inventoryPositionId()); var position = position(customer, request.inventoryPositionId());
var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId()); var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId());
@@ -124,9 +149,14 @@ public class CargoDiscardStockService {
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录"); if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
snapshotMarks(line, marks); snapshotMarks(line, marks);
} }
int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count(); var newlyHeld = selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).toList();
if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD")); int physical = selected.isEmpty() ? request.discardPackages() : newlyHeld.size();
if (physical > 0) {
relabelStock.handoff(customer, operator, application, line, newlyHeld);
commands.hold(customer, operator, move(application, line, physical, "HOLD"));
}
repository.saveItem(line); repository.saveItem(line);
return newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType()));
} }
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */ /** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
@@ -165,14 +195,18 @@ public class CargoDiscardStockService {
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */ /** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) { public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
var links = repository.selectedCartons(customer, application.getId());
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
changeSelectedCartons(customer, application, line, "AVAILABLE"); changeSelectedCartons(customer, application, line, "AVAILABLE");
int physical = physicalPackages(customer, application, line); int physical = physicalPackages(customer, application, line);
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE")); if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
relabelStock.restore(customer, application, line, links);
} }
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */ /** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) { public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数"); if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
if (!actualQuantityEditable(line) && actual != line.getDiscardPackages()) if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
throw conflict("必须完成本次申请所选全部箱子,不能漏出"); throw conflict("必须完成本次申请所选全部箱子,不能漏出");
@@ -203,6 +237,7 @@ public class CargoDiscardStockService {
/** 按箱来源执行带原状态条件的变更。 */ /** 按箱来源执行带原状态条件的变更。 */
private int changeBox(String customer, Long cartonId, String source, String from, String to) { private int changeBox(String customer, Long cartonId, String source, String from, String to) {
if ("RELABEL".equals(source)) return "DISCARD_PENDING".equals(to) ? relabelStock.claim(customer, cartonId) : relabelStock.finish(customer, cartonId, "DISCARDED".equals(to));
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to); if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
return repository.changeInboundCarton(customer, cartonId, from, to); return repository.changeInboundCarton(customer, cartonId, from, to);
@@ -237,8 +272,59 @@ public class CargoDiscardStockService {
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); } try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); } catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
} }
/** 可扫描范围只来自该申请明细仓位的原入仓及当前净实收箱唛。 */
public List<String> allowedCartonMarks(String customer, CargoDiscardItem line) {
position(customer, line.getInventoryPositionId());
var marks = repository.traceableMarks(customer, line.getInventoryPositionId());
if (!marks.isEmpty()) return marks;
var source = repository.bulkSource(customer, line.getInventoryPositionId());
if (source == null) return marks;
if (internalToken == null || internalToken.isBlank()) throw conflict("原入仓箱唛服务未就绪,请稍后重试");
var response = firstlegCartonMarks.marks(internalToken, Long.parseLong(source.sourceInboundId()), Long.parseLong(customer), source.inboundCode());
if (response == null || !"200".equals(response.code()) || response.data() == null) throw conflict("原入仓箱唛读取失败,请稍后重试");
if (response.data().size() != source.packageCount()) throw conflict("原入仓箱唛与整票实收数量不一致,请核对库存来源");
var allowed = new LinkedHashSet<>(response.data());
for (String snapshot : repository.discardedMarkSnapshots(customer, source.inboundCode())) {
try { allowed.removeAll(objectMapper.readValue(snapshot, new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {})); }
catch (Exception e) { throw new IllegalStateException("已废弃箱唛读取失败", e); }
}
return List.copyOf(allowed);
}
/** 无逐箱快照时仍需验证原入库箱唛归属,无法溯源的箱子拒绝出库。 */
public void validateActualCartonMarks(String customer, CargoDiscardItem line, List<String> marks) {
position(customer, line.getInventoryPositionId());
var allowed = new HashSet<>(allowedCartonMarks(customer, line));
if (!allowed.containsAll(marks)) throw conflict("箱唛不属于本废弃单的原入仓库存,请核对后重新扫描");
}
/** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */
public void saveActualCartonMarks(CargoDiscardItem line, List<String> marks) {
try { line.setActualCartonMarksJson(objectMapper.writeValueAsString(marks)); }
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛保存失败", e); }
}
/** 废弃详情读取真实提交的现场箱唛,历史未记录时返回空。 */
public List<String> actualCartonMarks(CargoDiscardItem line) {
if (line.getActualCartonMarksJson() == null) return List.of();
try { return objectMapper.readValue(line.getActualCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛读取失败", e); }
}
/** 无 SKU 来源保留未指定标记,不猜测商品。 */ /** 无 SKU 来源保留未指定标记,不猜测商品。 */
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); } private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
/** 当前可废弃数量包括已完成并确认的换标实箱,不包含其他业务冻结。 */
private long available(String customer, InventoryPosition position, InventoryBalance balance) {
long normal = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
long relabel = relabelStock.cartons(customer, position).size();
return normal + Math.min(relabel, value(balance.getHoldPackages()));
}
/** 原转运旧箱与当前换标实箱互斥,避免换托后重复展示。 */
private List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
var moved = relabelStock.transferred(customer);
var result = new ArrayList<>(repository.cartons(customer, position.getId()).stream()
.filter(c -> !"TRANSFER".equals(c.getSourceType()) || !moved.contains(c.getId())).toList());
result.addAll(relabelStock.cartons(customer, position));
return result;
}
/** 空库存桶按零处理。 */ /** 空库存桶按零处理。 */
private static long value(Long value) { return value == null ? 0 : value; } private static long value(Long value) { return value == null ? 0 : value; }
/** 返回可恢复的业务冲突。 */ /** 返回可恢复的业务冲突。 */
@@ -90,6 +90,14 @@ public class DispatchAggregateController {
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request)); return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
} }
/** 派送异常登记不触发签收或退货入库。 */ /** 派送异常登记不触发签收或退货入库。 */
/** 标记异常时按本车真实派送来源选择退回货物。 */
@GetMapping("/{dispatchNo}/exception-return-options")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<java.util.List<com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Candidate>> exceptionCandidates(
@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.exceptionCandidates(DispatchActorFactory.platform(user),dispatchNo));
}
@PostMapping("/{dispatchNo}/exceptions") @PostMapping("/{dispatchNo}/exceptions")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) { public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.service.DispatchLooseboardExecutionService;
import com.baiyue.lastleg.dispatch.service.DispatchLooseboardExecutionService.*;
import com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 散板取货/送达照片复用排车list/edit权限,前端不能提交商业归属。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/dispatches/{number}/looseboard-execution")
public class DispatchLooseboardExecutionController {
private final DispatchLooseboardExecutionService service;
/** 入口只映射认证主体和原车。 */
public DispatchLooseboardExecutionController(DispatchLooseboardExecutionService service){this.service=service;}
/** 返回逐票照片及服务端节点资格。 */
@GetMapping @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<Detail> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){return ApiResponse.success(service.get(DispatchActorFactory.platform(user),number));}
/** 显式确认取货,不由上传照片代替操作者提交。 */
@PostMapping("/pickup-complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Detail> completePickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody Confirm request){return ApiResponse.success(service.completePickup(DispatchActorFactory.platform(user),number,request));}
/** 上传不提前推进业务状态。 */
@PostMapping(value="/{itemId}/{phase}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<File> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@RequestPart("file") MultipartFile file){return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),number,itemId,phase,file));}
/** 照片保存与节点推进同事务,公共版本防止并发覆盖。 */
@PutMapping("/{itemId}/{phase}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@Valid @RequestBody Save request){return ApiResponse.success(service.save(DispatchActorFactory.platform(user),number,itemId,phase,request));}
/** 当前绑定照片按原车、明细、类别读取,无公共下载地址。 */
@GetMapping("/{itemId}/{phase}/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@PathVariable @Positive long fileId){
var file=service.content(DispatchActorFactory.platform(user),number,itemId,phase,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(file.file().contentType())).header("X-Content-Type-Options","nosniff").body(file.bytes());
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.service.LooseboardCargoService;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import jakarta.validation.constraints.*;
import org.springframework.validation.annotation.Validated;
/** 散板需求加货需同时具备需求池读取和排车维护权限。 */
@RestController @Validated
@RequestMapping("/api/platform/v1/last-leg/dispatches/capacity-demands/{dispatchNo}/cargo")
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage') and hasAuthority('platform:overseas-warehouse:dispatch:list')")
public class LooseboardCargoController {
private final LooseboardCargoService service;
public LooseboardCargoController(LooseboardCargoService service) { this.service=service; }
@GetMapping
public ApiResponse<DispatchPlanningModels.CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(50) int size) {
return ApiResponse.success(service.candidates(DispatchActorFactory.platform(user),dispatchNo,page,size));
}
@PostMapping
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:cargo-add','platform:fleet:capacity-offer:manage') and hasAuthority('platform:overseas-warehouse:dispatch:edit') and hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<DispatchContracts.CommandResult> add(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.AddCargo request) {
return ApiResponse.success(service.add(DispatchActorFactory.platform(user),dispatchNo,request));
}
}
@@ -25,6 +25,7 @@ import java.util.stream.Collectors;
@Component @Component
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public class DispatchFleetAdapter implements FleetDispatchGateway { public class DispatchFleetAdapter implements FleetDispatchGateway {
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts looseboardFacts;
private final DispatchAggregateRepository aggregates; private final DispatchAggregateRepository aggregates;
private final OutboundOrderMapper outbounds; private final OutboundOrderMapper outbounds;
private final DeliveryOrderMapper deliveries; private final DeliveryOrderMapper deliveries;
@@ -79,7 +80,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
} }
@Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) { @Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) {
var context=workflow.fleetContext(number,lock);var row=required(number,false); var context=workflow.fleetContext(number,lock);var row=required(number,false);
var cargo=aggregates.cargo(row.getId()).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(), var cargo=warehouseItems(row).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(),
destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList(); destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList();
String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null; String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null;
var vehicle=context.resources().vehicle();var driver=context.resources().driver(); var vehicle=context.resources().vehicle();var driver=context.resources().driver();
@@ -90,10 +91,11 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
var row=required(number,lock); var row=required(number,lock);
var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList(); var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList();
var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList(); var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList();
var expected=aggregates.cargo(row.getId()).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount()))); var expected=warehouseItems(row).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount())));
var byCustomer=new HashMap<String,OutboundOrder>(); var byCustomer=new HashMap<String,OutboundOrder>();
for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致"); for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致");
if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物"); long remotePicked=looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);
if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()+remotePicked!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物");
var counts=new HashMap<String,Long>();var numbers=new HashSet<String>(); var counts=new HashMap<String,Long>();var numbers=new HashSet<String>();
for(var delivery:deliveryRows) { for(var delivery:deliveryRows) {
var outbound=byCustomer.get(delivery.getCustomerId()); var outbound=byCustomer.get(delivery.getCustomerId());
@@ -107,12 +109,20 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
@Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) { @Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) {
var row=required(number,lock); var row=required(number,lock);
if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成"); if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成");
if(looseboardFacts!=null && !looseboardFacts.cargo(row.getId()).isEmpty()) {
boolean ready=(looseboardFacts.remoteItemIds(row.getId()).isEmpty() || looseboardFacts.pickupConfirmed(row)) && looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.PICKUP)
&& looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.ARRIVAL);
if(looseboardFacts.direct(row))return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),looseboardFacts.outcomes(row),ready?null:"散板逐票取货和送达照片尚未齐全");
if(!ready)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"混排散板逐票取货和送达照片尚未齐全");
}
var facts=facts(number,lock);var outcomes=new ArrayList<FleetCompletionModels.Outcome>(); var facts=facts(number,lock);var outcomes=new ArrayList<FleetCompletionModels.Outcome>();
for(var delivery:facts.deliveries()) { for(var delivery:facts.deliveries()) {
var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts); var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts);
Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null; Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null;
outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining())); outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining()));
} }
if(looseboardFacts!=null){var remote=looseboardFacts.remoteItemIds(row.getId());var orders=looseboardFacts.cargo(row.getId()).stream().filter(c->remote.contains(c.item().getId())).map(c->c.item().getCustomerId()+":"+c.orderNo()).collect(Collectors.toSet());
outcomes.addAll(looseboardFacts.outcomes(row).stream().filter(o->orders.contains(o.customerId()+":"+o.deliveryNo())).toList());}
String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null; String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null;
return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason); return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason);
} }
@@ -140,6 +150,8 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
if(deliveries.update(null,new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.now()))!=1)throw conflict("派送交接保存失败"); if(deliveries.update(null,new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.now()))!=1)throw conflict("派送交接保存失败");
} }
} }
/** 混排照片取货不产生客户出库单,历史实托和其他来源仍按原链核对。 */
private List<com.baiyue.lastleg.dispatch.entity.DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?aggregates.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());}
private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;} private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;}
private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);} private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);}
/** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */ /** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */
@@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile;
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */ /** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
public interface DispatchEvidenceGateway { public interface DispatchEvidenceGateway {
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION } enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION, LOOSEBOARD_PICKUP, LOOSEBOARD_ARRIVAL }
record File(Long fileId,String fileName,String contentType,long sizeBytes) { } record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
/** 上传只写本车、本类证据。 */ /** 上传只写本车、本类证据。 */
File upload(long dispatchId,Kind kind,String operator,MultipartFile file); File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
@@ -85,7 +85,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
} }
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */ /** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
private static boolean supportedType(Kind kind,String contentType) { private static boolean supportedType(Kind kind,String contentType) {
return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType)); return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION && kind!=Kind.LOOSEBOARD_PICKUP && kind!=Kind.LOOSEBOARD_ARRIVAL) || !"application/pdf".equals(contentType));
} }
/** 无效证据不能作为已核实资料。 */ /** 无效证据不能作为已核实资料。 */
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
@@ -36,9 +36,13 @@ public interface DispatchAggregateMapper {
"""; """;
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END"; String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END"; String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END";
// 仓内已发运而异地货尚未确认取齐,同时属于装车后和派送中;确认取齐后仅保留派送中。
String PICKUP_PENDING = "(d.stage='DELIVERING' AND d.departed_at IS NOT NULL AND d.shipped_package_count &lt; d.package_count)";
String CURRENT_FILTER = """ String CURRENT_FILTER = """
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if> <if test='q.status != "ALL"'> AND d.status=#{q.status}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if> <if test='q.stage != null'><choose><when test='q.stage == "AFTER_LOADING"'> AND (d.stage='AFTER_LOADING' OR
""" + PICKUP_PENDING + """
)</when><otherwise> AND d.stage=#{q.stage}</otherwise></choose></if>
<if test='q.section == "VEHICLE_INFO"'> AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED')</if> <if test='q.section == "VEHICLE_INFO"'> AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED')</if>
<if test='q.section == "DISPATCH_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING'</if> <if test='q.section == "DISPATCH_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING'</if>
<if test='q.section == "LOADING_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING'</if> <if test='q.section == "LOADING_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING'</if>
@@ -49,7 +53,10 @@ public interface DispatchAggregateMapper {
record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId, record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId,
String fbaAppointmentNo, String status, String stage, String section, String reviewStatus, String fbaAppointmentNo, String status, String stage, String section, String reviewStatus,
long offset, int limit) { } long offset, int limit) { }
record Summary(String status, String stage, String reviewState, long quantity) { } record Summary(String status, String stage, String reviewState, long quantity,long pendingPickups) {
@AutomapConstructor public Summary { }
public Summary(String status,String stage,String reviewState,long quantity){this(status,stage,reviewState,quantity,0);}
}
/** 首次创建整车所有权及初始版本,不写customer_id。 */ /** 首次创建整车所有权及初始版本,不写customer_id。 */
@Insert(""" @Insert("""
@@ -95,7 +102,7 @@ public interface DispatchAggregateMapper {
long count(@Param("q") Search query); long count(@Param("q") Search query);
/** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */ /** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */
@Select("<script>SELECT d.status,d.stage,d.review_state AS reviewState,COUNT(*) AS quantity FROM ll_dispatch_order d WHERE 1=1 " @Select("<script>SELECT d.status,d.stage,d.review_state AS reviewState,COUNT(*) AS quantity,SUM(CASE WHEN " + PICKUP_PENDING + " THEN 1 ELSE 0 END) AS pendingPickups FROM ll_dispatch_order d WHERE 1=1 "
+ BASE_FILTER + " GROUP BY d.status,d.stage,d.review_state</script>") + BASE_FILTER + " GROUP BY d.status,d.stage,d.review_state</script>")
List<Summary> summaries(@Param("q") Search query); List<Summary> summaries(@Param("q") Search query);
@@ -26,6 +26,14 @@ public interface DispatchCandidateMapper {
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" + "AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " + "SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))"; "AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
/** 散板审核后的同仓申报可预排;取消、未审和普通转运不取得此资格。 */
String APPROVED_LOOSEBOARD = "EXISTS (SELECT 1 FROM ll_local_looseboard_order lb JOIN ll_warehouse_transfer_order lt " +
"ON lt.transfer_no=lb.transfer_no AND lt.customer_id=lb.customer_id " +
"WHERE lb.lastleg_order_id=ip.source_order_id AND lb.customer_id=ip.customer_id " +
"AND lb.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED') " +
"AND lt.lastleg_order_id=ip.source_order_id AND CAST(lt.operating_warehouse_id AS DECIMAL(20,0))=ip.overseas_warehouse_id " +
"AND lt.status IN ('WAIT_PDA_RECEIVING','WAIT_CUSTOMER_CONFIRMATION','READY_FOR_DISPATCH') " +
"AND p.source_pallet_no LIKE 'LB-PLAN:%')";
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan. // Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
String CONFIRMED_APPOINTMENT_AT = String CONFIRMED_APPOINTMENT_AT =
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " + "(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
@@ -180,7 +188,7 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + "(" + DEPARTED_PLAN + " OR " + APPROVED_LOOSEBOARD + "))) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
@@ -285,7 +293,7 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + "(" + DEPARTED_PLAN + " OR " + APPROVED_LOOSEBOARD + "))) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
@@ -328,6 +336,8 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " + "LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " + "AND wtp.warehouse_pallet_id=pos.pallet_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
// 部分废弃后只统计仍在仓内的换标箱,保留原任务供追溯。
"AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=ri.customer_id AND dc.carton_id=ri.id AND dc.source_type='RELABEL_DISCARDED') " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" + "<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" + "<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" + "<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 散板照片履约事实追加保存在排车历史,不生成库存或拣货流水。 */
@Mapper
public interface DispatchLooseboardEvidenceMapper {
/** 按原车读取不可变照片命令,版本顺序用于恢复和审计。 */
@Select("SELECT snapshot_json FROM ll_dispatch_history WHERE dispatch_id=#{id} AND action='LOOSEBOARD_PHOTO' ORDER BY record_version,id")
List<String> snapshots(long id);
/** 取货完成是独立确认动作,绑定当前货物及审核轮次。 */
@Select("SELECT snapshot_json FROM ll_dispatch_history WHERE dispatch_id=#{id} AND action='LOOSEBOARD_PICKUP_COMPLETE' ORDER BY record_version,id")
List<String> pickupConfirmations(long id);
/** 同客户原单逐排车明细去重汇总送达量,分车派送不能提前完成原单。 */
@Select("""
SELECT COALESCE(SUM(done.packages),0) FROM (
SELECT i.id,MAX(i.package_count) packages FROM ll_dispatch_history h
JOIN ll_dispatch_order d ON d.id=h.dispatch_id AND d.status='ACTIVE'
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND CAST(i.id AS CHAR)=JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.itemId'))
WHERE h.action='LOOSEBOARD_PHOTO' AND i.customer_id=#{customer}
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.customerId'))=#{customer}
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.orderNo'))=#{orderNo}
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.phase'))='ARRIVAL'
AND JSON_LENGTH(JSON_EXTRACT(h.snapshot_json,'$.files'))>0
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.cargoHash'))=d.cargo_hash
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.reviewRound')) AS UNSIGNED)=d.review_round
GROUP BY i.id
) done
""")
long deliveredPackages(@Param("customer") String customer,@Param("orderNo") String orderNo);
}

Some files were not shown because too many files have changed in this diff Show More