Compare commits

...
Author SHA1 Message Date
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
liuyihe c20107c278 Merge remote-tracking branch 'origin/test' into test 2026-10-27 18:28:32 +08:00
liuyihe f418f9a05f 费用提交 2026-10-27 18:27:56 +08:00
ouyangxq 705c732ffd Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-10-27 17:44:43 +08:00
ouyangxq db0e72daeb 优化 2026-10-27 17:44:41 +08:00
liuyihe 423e40e6aa 费用提交 2026-10-27 17:41:33 +08:00
ouyangxq 57cd211973 fix(firstleg): 统一船期跨表查询文本比较规则 2026-10-27 17:10:58 +08:00
ouyangxq ab457e022d fix(firstleg): 修正船期货量顺延与订柜建议 2026-10-27 16:56:20 +08:00
ouyangxq ed6a173b86 fix(finance): preserve voided receivables and return current revisions 2026-10-27 15:30:28 +08:00
ouyangxq fe89b8a2b8 fix(finance): revalidate source ownership before reopening settlements 2026-10-27 14:56:20 +08:00
ouyangxq 83f2fda4cd fix(finance): reconcile payment progress audit amounts and customer names 2026-10-27 12:08:35 +08:00
ouyangxq 00616d5930 fix(lastleg): resolve transfer storage rent by inbound association 2026-10-26 19:58:07 +08:00
ouyangxq 3e0d8d6c88 test(dispatch): verify multi-photo evidence and synchronize PDF storage assertions 2026-10-26 19:47:18 +08:00
ouyangxq a5befc9bad fix(dispatch): invalidate resource evidence and bind uploads to review rounds 2026-10-26 19:21:11 +08:00
ouyangxq f6d9991955 优化 2026-10-26 18:52:13 +08:00
ouyangxq c2735cd906 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-10-26 17:30:19 +08:00
ouyangxq 2927fbee06 优化 2026-10-26 17:27:32 +08:00
admin be9f5d2a30 Merge pull request '同步排车档案照片与装车多图至 TEST' (#38) from hotfix-gray-bugfix into test 2026-10-26 17:26:10 +08:00
ouyangxq 6577b2aa03 test(lastleg): isolate photo table DDL from subsequent rent migrations 2026-10-26 17:25:26 +08:00
ouyangxq ca02ed0c17 merge: retain verified TEST regression fixture fixes 2026-10-26 17:21:43 +08:00
ouyangxq 1928d2e5cf merge: preserve TEST finance and rent changes with fleet photo updates 2026-10-26 17:21:43 +08:00
admin e9ef12f078 Merge pull request 'feat(lastleg): 排车复用车辆司机档案照片并支持装车多图' (#37) from hotfix-gray-bugfix into main 2026-10-26 17:15:07 +08:00
ouyangxq 5eabfa395b feat(lastleg): reuse fleet photos and support multiple loading images 2026-10-26 17:12:36 +08:00
ouyangxq 5b53be23e8 fix(finance): show direct-paid costs as fully paid 2026-10-26 16:45:36 +08:00
ouyangxq c6bc3ac85b test(lastleg): specify departed scope for database pagination check 2026-10-26 15:54:43 +08:00
admin 5ad599bd66 Merge pull request 'fix(lastleg): 短少确认按实收入库并交接空托盘预排' (#36) from fix/inbound-shortage-confirmation into main 2026-10-26 15:47:29 +08:00
ouyangxq e27881808d fix(lastleg): confirm inbound shortages and reconcile empty preplanned pallets 2026-10-26 15:46:13 +08:00
liuyihe 3f8a0df433 费用提交 2026-10-26 15:35:14 +08:00
admin ba0bf9ebb7 Merge pull request 'fix(lastleg): 排车排除入库结束后未实收的空托盘' (#35) from hotfix-gray-bugfix into main 2026-10-26 15:16:12 +08:00
ouyangxq 8d211acf5b fix(lastleg): exclude unreceived pallets after inbound completion 2026-10-26 15:11:32 +08:00
admin 5e9cebcf85 Merge pull request 'fix(firstleg): 一件代发待组托允许确认并发送费用' (#34) from test into main 2026-10-26 11:46:35 +08:00
389 changed files with 10589 additions and 9446 deletions
@@ -11,7 +11,14 @@
两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `CREATE TABLE` 中,菜单及授权使用初始化种子;不追加存量改造、权限版本回填或动态迁移段。目标 schema 必须为空,按文件原有初始化流程执行;不能向已有业务库重放。
本次不再提供这项功能的独立升级脚本。已有库升级必须按实际结构和明确授权另行准备、审阅受控增量,不能把删除重复文件理解为已有库已完成升级。
已有库只按实际缺项执行定向结构补齐,不能把删除重复文件理解为已有库已完成升级。指定 TEST 的最小执行集合如下;文件准备不代表获得执行授权或已部署。
| 顺序 | 文件 | 允许的变更与核对结果 |
|---|---|---|
| 1 | [表与审核人字段补齐](overseas-fees-storage-rent-structure-add.sql) | 只补 `ll_storage_rent_inbound`、`ll_finance_fee_source.reviewer_name`;已存在且匹配时跳过,同名异构则停止;成功回查表结构、字段并输出 `TABLE_COLUMN_READY` |
| 2 | [免费仓租定向修复](overseas-storage-rent-zero-fee-repair.sql) | 仅自动应收仓租允许原币/结算双零与空汇率;保留其他费用正额及有效汇率约束,保留已有汇率精度与数值默认值;最终核对 `SHOW CREATE TABLE` 中金额、汇率两项 CHECK 和汇率可空性 |
两步均固定 `baiyue_lastleg`,使用独立连接、支持 `DELIMITER` 的客户端,禁止遇错继续;不执行业务 DML、不补历史计租记录、不变更余额、汇率配置或权限。DDL 隐式提交,不能宣称整体可回滚;失败保留错误并只读复查,已完成项不删除重建。两步是已核缺项的补齐方案,不是任意版本升级工具;重新执行前仍需核对目标实例与结构。
## 海外表和字段
+24 -3
View File
@@ -1,9 +1,27 @@
# 单测试环境数据库空库初始化
`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 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
`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-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 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-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
@@ -77,7 +95,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 238 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 222 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -100,7 +118,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/238`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/222`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -201,8 +219,11 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照、审核人名称,以及采购退款请求幂等字段和唯一索引;同步模块源基线、国内权限种子及海外空库 238 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。
海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照及审核人名称;采购旧费用与退款专属表已在简化流程退役,不再进入空库初始化。同步模块源基线、国内权限种子及海外空库 222 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
采购方向扩展:`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,11 @@
-- 仅用于单独授权的 TEST:修正作废应收仍占用原对账单的唯一约束。
-- 不更新、删除或回填业务记录;作废单的原对账单关联与修订历史保持不变。
-- 一条原子 ALTER 同时替换约束;有效应收重复、原索引缺失或目标列已存在均应报错停止。
-- 使用独立连接并开启遇错停止;若结果不明确,先核对结构,不盲目重放。
-- 前置:已部署 Finance 结算重开校验(fe89b8a2b875),目标为 MySQL 8 / InnoDB。
ALTER TABLE baiyue_finance.fin_settlement
ADD COLUMN active_receivable_statement_id BIGINT UNSIGNED
GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END)
VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留',
DROP INDEX uk_fin_settlement_receivable_statement,
ADD UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id, active_receivable_statement_id);
@@ -3,6 +3,16 @@
-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。
USE baiyue_lastleg;
-- 排车保存会同步异常快照;旧库必须先补齐该正式字段。
SET @dispatch_exception_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'exceptions_json'),
'SELECT 1',
'ALTER TABLE ll_dispatch_order ADD COLUMN exceptions_json JSON NULL COMMENT ''派送异常登记记录及图片引用,不改变派送及库存状态'' AFTER review_basis_hash'
);
PREPARE dispatch_exception_stmt FROM @dispatch_exception_ddl;
EXECUTE dispatch_exception_stmt;
DEALLOCATE PREPARE dispatch_exception_stmt;
SET @dispatch_transport_column_ddl = IF(
EXISTS (
SELECT 1 FROM information_schema.columns
@@ -0,0 +1,16 @@
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
@@ -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,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,97 @@
-- 指定 TEST 的海外费用/仓租结构补齐;实例必须由执行者另行核对并获准。
-- 仅新增 ll_storage_rent_inbound 和 ll_finance_fee_source.reviewer_name。
-- 使用独立连接,支持 DELIMITER,遇错停止;禁止 mysql --force。
-- 不更新费用、库存、钱包或历史计租数据,不重放空库初始化。
-- DDL 隐式提交;失败先回查结构,禁止删除已建表/字段来重跑。
USE baiyue_lastleg;
-- 仅清理本脚本可能遗留的专用校验过程,不删除业务对象。
DROP PROCEDURE IF EXISTS overseas_fee_rent_structure_add_20261026;
DELIMITER $$
CREATE PROCEDURE overseas_fee_rent_structure_add_20261026()
BEGIN
DECLARE inbound_exists INT DEFAULT 0;
DECLARE reviewer_exists INT DEFAULT 0;
IF DATABASE()<>'baiyue_lastleg' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg';
END IF;
-- 在首个业务 DDL 前核对两张依赖表及新增字段的定位列。
IF (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_finance_fee_source','ll_storage_rent_lot')
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB')<>2
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
AND COLUMN_NAME='reviewer_id' AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=128)<>1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用来源或计租批次基础结构不匹配,停止补齐';
END IF;
SELECT COUNT(*) INTO reviewer_exists FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name';
IF reviewer_exists>0 AND (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name'
AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=255 AND IS_NULLABLE='YES'
AND COLUMN_DEFAULT IS NULL AND EXTRA='')<>1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的审核人名称字段与正式定义不同,停止且不覆盖';
END IF;
SELECT COUNT(*) INTO inbound_exists FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound';
-- 已存在的同名表必须完整匹配;不以 IF NOT EXISTS 掩盖半成品或异构表。
IF inbound_exists>0 THEN
IF (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_unicode_ci')<>1
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA=''
AND ((COLUMN_NAME IN ('receipt_id','lot_id') AND COLUMN_TYPE='bigint')
OR (COLUMN_NAME='quantity' AND COLUMN_TYPE='decimal(24,12)')
OR (COLUMN_NAME='created_at' AND COLUMN_TYPE='datetime(3)')))<>4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的计租实收表字段与正式定义不同,停止且不覆盖';
END IF;
IF (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY')<>1
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY'
AND COLUMN_NAME='receipt_id' AND SEQ_IN_INDEX=1 AND NON_UNIQUE=0 AND SUB_PART IS NULL)<>1
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND INDEX_NAME='idx_storage_rent_inbound_lot')<>1
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND INDEX_NAME='idx_storage_rent_inbound_lot' AND COLUMN_NAME='lot_id'
AND SEQ_IN_INDEX=1 AND NON_UNIQUE=1 AND SUB_PART IS NULL)<>1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='计租实收表主键或批次索引不匹配,停止且不覆盖';
END IF;
END IF;
-- 全部前置检查通过后才补缺项;重跑时保持已存在的业务结构和数据。
IF reviewer_exists=0 THEN
ALTER TABLE ll_finance_fee_source
ADD COLUMN reviewer_name VARCHAR(255) NULL COMMENT '审核人名称快照' AFTER reviewer_id;
END IF;
IF inbound_exists=0 THEN
CREATE TABLE ll_storage_rent_inbound (
receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量',
lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键',
quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数',
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
PRIMARY KEY (receipt_id),
KEY idx_storage_rent_inbound_lot (lot_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记';
END IF;
END$$
DELIMITER ;
CALL overseas_fee_rent_structure_add_20261026();
DROP PROCEDURE overseas_fee_rent_structure_add_20261026;
SHOW CREATE TABLE ll_storage_rent_inbound;
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name';
SELECT 'TABLE_COLUMN_READY' AS structure_status;
-- TABLE_COLUMN_READY 只表示上述表/字段补齐;免费仓租另执行已审阅的
-- overseas-storage-rent-zero-fee-repair.sql,不能据此宣称完整功能上线。
@@ -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,102 @@
-- 海外采购旧流程专属表退役,MySQL 8.0+,目标仅 baiyue_lastleg。
-- 必须先停止旧版 Lastleg 写入并部署本次分离版本,验证新流程后在维护窗口执行。
-- 永久删除下面明确列出的 21 张旧采购表及其记录,DDL 隐式提交;禁止 mysql --force。
-- 保留主单、审核流水、采购附件、分批收货、出库及幂等六表;不改任何共用财务、资金、文件或仓储表。
-- 旧资金表有记录或旧状态采购未处理时拒绝,不自动清除、回填或伪造资金完成。
-- 运行者须在同一连接显式 SET @procurement_retirement_release='procurement-six-tables-20261027';
-- 文件准备不表示数据库已执行。先审核备份及运行版本,按清单整份执行,遇错停止。
USE baiyue_lastleg;
DELIMITER $$
DROP PROCEDURE IF EXISTS retire_procurement_legacy_tables$$
CREATE PROCEDURE retire_procurement_legacy_tables()
BEGIN
IF DATABASE()<>'baiyue_lastleg' OR COALESCE(@procurement_retirement_release,'')<>'procurement-six-tables-20261027' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Confirm the six-table procurement release and target schema first';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN (
'll_overseas_procurement_order','ll_overseas_procurement_status_history','ll_overseas_procurement_file',
'll_procurement_workflow_receipt','ll_procurement_workflow_outbound','ll_procurement_workflow_command'))<>6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Six active procurement tables must exist before retiring legacy tables';
END IF;
IF EXISTS(SELECT 1 FROM ll_overseas_procurement_order WHERE status NOT IN
('PENDING_REVIEW','RECEIVING','WAIT_CUSTOMER_CONFIRM','CONFIRMED','IN_TRANSIT','DELIVERING','COMPLETED','REJECTED','CANCELLED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement orders remain; resolve them before retirement';
END IF;
-- 仅只读核对共用资金记录;不删除资金命令或回执,不把已投递当作已结清。
IF EXISTS(SELECT 1 FROM ll_event_outbox WHERE aggregate_type='PROCUREMENT_FEE')
OR EXISTS(SELECT 1 FROM ll_wallet_event_inbox
WHERE JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.businessType'))='PROCUREMENT_FEE') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement wallet events exist; financial review is required';
END IF;
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_fee') THEN
SET @procurement_retirement_financial_rows=0;
SET @procurement_retirement_check_sql='SELECT COUNT(*) INTO @procurement_retirement_financial_rows FROM baiyue_lastleg.ll_overseas_procurement_fee';
PREPARE procurement_retirement_check FROM @procurement_retirement_check_sql;
EXECUTE procurement_retirement_check;
DEALLOCATE PREPARE procurement_retirement_check;
IF @procurement_retirement_financial_rows<>0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement financial records remain; do not delete financial evidence';
END IF;
END IF;
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_procurement_wallet_attempt') THEN
SET @procurement_retirement_financial_rows=0;
SET @procurement_retirement_check_sql='SELECT COUNT(*) INTO @procurement_retirement_financial_rows FROM baiyue_lastleg.ll_procurement_wallet_attempt';
PREPARE procurement_retirement_check FROM @procurement_retirement_check_sql;
EXECUTE procurement_retirement_check;
DEALLOCATE PREPARE procurement_retirement_check;
IF @procurement_retirement_financial_rows<>0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement financial records remain; do not delete financial evidence';
END IF;
END IF;
-- 不关闭外键校验;来自其他业务表的外键也会使这条精确删除失败。
DROP TABLE IF EXISTS
ll_procurement_closure_item,
ll_procurement_closure,
ll_procurement_control_note,
ll_procurement_fee_correction_item,
ll_procurement_fee_correction,
ll_procurement_repack,
ll_procurement_estimate_review,
ll_procurement_label_item,
ll_procurement_label_set,
ll_procurement_shipment_item,
ll_procurement_shipment,
ll_procurement_carton_source,
ll_procurement_carton,
ll_procurement_manifest,
ll_procurement_disposition,
ll_procurement_receipt,
ll_procurement_budget_authorization,
ll_procurement_command,
ll_procurement_flow,
ll_procurement_wallet_attempt,
ll_overseas_procurement_fee;
END$$
CALL retire_procurement_legacy_tables()$$
DROP PROCEDURE retire_procurement_legacy_tables$$
DELIMITER ;
-- 若上面任一步失败必须停止。剩余旧表数应为 0;此 SELECT 不代替客户端退出码核对。
SELECT COUNT(*) AS remaining_legacy_procurement_tables
FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name IN (
'll_overseas_procurement_fee',
'll_procurement_wallet_attempt',
'll_procurement_flow',
'll_procurement_command',
'll_procurement_budget_authorization',
'll_procurement_receipt',
'll_procurement_disposition',
'll_procurement_manifest',
'll_procurement_carton',
'll_procurement_carton_source',
'll_procurement_shipment',
'll_procurement_shipment_item',
'll_procurement_label_set',
'll_procurement_label_item',
'll_procurement_estimate_review',
'll_procurement_repack',
'll_procurement_fee_correction',
'll_procurement_fee_correction_item',
'll_procurement_control_note',
'll_procurement_closure',
'll_procurement_closure_item');
@@ -0,0 +1,38 @@
-- 海外采购退役表只读核查;2026-10-27 分离版本。MySQL 8.0+,仅查询,不自动执行删除。
-- 6 张当前业务表保留;21 张旧采购专属表已从生产代码和初始化基线移除。
-- 只有先部署分离版本并通过 legacy-tables-retire.sql 前置校验后,才可执行精确删表清单。
-- 元数据行数是估算;actual_count_sql 供逐表只读核对,不自动执行。
WITH inventory AS (
SELECT 'll_overseas_procurement_order' AS table_name, 'KEEP' AS disposition
UNION ALL SELECT 'll_overseas_procurement_status_history' AS table_name, 'KEEP' AS disposition
UNION ALL SELECT 'll_overseas_procurement_file' AS table_name, 'KEEP' AS disposition
UNION ALL SELECT 'll_procurement_workflow_receipt' AS table_name, 'KEEP' AS disposition
UNION ALL SELECT 'll_procurement_workflow_outbound' AS table_name, 'KEEP' AS disposition
UNION ALL SELECT 'll_procurement_workflow_command' AS table_name, 'KEEP' AS disposition
UNION ALL SELECT 'll_overseas_procurement_fee' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_wallet_attempt' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_flow' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_command' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_budget_authorization' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_receipt' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_disposition' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_manifest' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_carton' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_carton_source' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_shipment' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_shipment_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_label_set' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_label_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_estimate_review' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_repack' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_fee_correction' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_fee_correction_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_control_note' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_closure' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
UNION ALL SELECT 'll_procurement_closure_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
)
SELECT i.table_name,i.disposition,CASE WHEN t.table_name IS NULL THEN 'MISSING' ELSE 'EXISTS' END AS database_state,
t.table_rows AS table_rows_estimate,
CASE WHEN t.table_name IS NOT NULL THEN CONCAT('SELECT COUNT(*) AS actual_rows FROM baiyue_lastleg.',i.table_name,';') END AS actual_count_sql
FROM inventory i LEFT JOIN information_schema.tables t ON t.table_schema='baiyue_lastleg' AND t.table_name=i.table_name
ORDER BY i.disposition,i.table_name;
@@ -0,0 +1,65 @@
-- 仅用于用户确认可丢弃数据的海外采购测试环境,MySQL 8.0+。
-- 清空六张当前采购表的数据,删除二十一张旧采购表及其数据。
-- 仅作用于 baiyue_lastleg 的采购专属表;不修改共用费用、余额、文件对象及库存。
-- 共用表中已有的采购关联记录不会自动删除或退款;本脚本不用于财务结算。
-- 先更新分离后的后端并停止采购写入,整份执行,遇错停止,不使用 --force。
-- 无存储过程、无 DELIMITER、无需设置会话确认变量;DDL 隐式提交。
USE baiyue_lastleg;
START TRANSACTION;
DELETE FROM ll_procurement_workflow_command;
DELETE FROM ll_procurement_workflow_outbound;
DELETE FROM ll_procurement_workflow_receipt;
DELETE FROM ll_overseas_procurement_file;
DELETE FROM ll_overseas_procurement_status_history;
DELETE FROM ll_overseas_procurement_order;
COMMIT;
DROP TABLE IF EXISTS
ll_procurement_closure_item,
ll_procurement_closure,
ll_procurement_control_note,
ll_procurement_fee_correction_item,
ll_procurement_fee_correction,
ll_procurement_repack,
ll_procurement_estimate_review,
ll_procurement_label_item,
ll_procurement_label_set,
ll_procurement_shipment_item,
ll_procurement_shipment,
ll_procurement_carton_source,
ll_procurement_carton,
ll_procurement_manifest,
ll_procurement_disposition,
ll_procurement_receipt,
ll_procurement_budget_authorization,
ll_procurement_command,
ll_procurement_flow,
ll_procurement_wallet_attempt,
ll_overseas_procurement_fee;
SELECT 'procurement_test_reset_complete' AS result,
(SELECT COUNT(*) FROM ll_overseas_procurement_order) AS procurement_orders,
(SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_lastleg' AND table_name IN (
'll_overseas_procurement_fee',
'll_procurement_wallet_attempt',
'll_procurement_flow',
'll_procurement_command',
'll_procurement_budget_authorization',
'll_procurement_receipt',
'll_procurement_disposition',
'll_procurement_manifest',
'll_procurement_carton',
'll_procurement_carton_source',
'll_procurement_shipment',
'll_procurement_shipment_item',
'll_procurement_label_set',
'll_procurement_label_item',
'll_procurement_estimate_review',
'll_procurement_repack',
'll_procurement_fee_correction',
'll_procurement_fee_correction_item',
'll_procurement_control_note',
'll_procurement_closure',
'll_procurement_closure_item'
)) AS remaining_legacy_procurement_tables;
@@ -0,0 +1,36 @@
-- 简化采购独立批次表;只增加结构,不回填旧采购记录,执行需另行授权。
USE baiyue_lastleg;
-- 简化采购:独立采购库存,收货、占用和签收扣减均由订单锁串行控制。
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt (
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
order_id BIGINT NOT NULL COMMENT '采购单主键',
quantity BIGINT NOT NULL COMMENT '本批次数量',
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
received_at DATETIME(3) NOT NULL COMMENT '本批次实收时间',
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
KEY idx_procurement_workflow_receipt (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批收货照片与实收数量';
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound (
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
order_id BIGINT NOT NULL COMMENT '采购单主键',
outbound_no VARCHAR(64) NOT NULL COMMENT '采购出库单号',
quantity BIGINT NOT NULL COMMENT '本批次数量',
tracking_no VARCHAR(255) NOT NULL COMMENT '物流跟踪号',
status VARCHAR(32) NOT NULL COMMENT '批次运输派送及签收状态',
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
created_at DATETIME(3) NOT NULL COMMENT '记录创建时间',
completed_at DATETIME(3) NULL COMMENT '本批次签收完成时间',
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
UNIQUE KEY uk_procurement_workflow_outbound_no (outbound_no),
KEY idx_procurement_workflow_outbound (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批出库物流与签收凭证';
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
order_id BIGINT NOT NULL COMMENT '采购单主键',
command_key VARCHAR(128) NOT NULL COMMENT '本单操作幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '动作及请求参数哈希',
operator_id VARCHAR(128) NOT NULL COMMENT '作业人标识',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
@@ -1,4 +1,4 @@
-- 固定目标本机 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。
-- 固定目标经单独授权的 TEST 或本机开发 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。
-- 使用独立连接、遇错停止;先核对既有表结构,不能重放空库初始化。
USE baiyue_lastleg;
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS
@@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
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 '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -5044,6 +5046,7 @@ CREATE TABLE fin_settlement (
due_date date NOT NULL COMMENT '结算到期日期',
payment_status varchar(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
status varchar(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
active_receivable_statement_id bigint unsigned GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留',
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID',
@@ -5051,7 +5054,7 @@ CREATE TABLE fin_settlement (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_no (tenant_id,settlement_no),
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id,reconciliation_statement_id,direction),
UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id,active_receivable_statement_id),
KEY idx_fin_settlement_customer_direction_status (tenant_id,direction,status),
KEY idx_fin_settlement_supplier_status (supplier_id,direction,status),
KEY idx_fin_settlement_receivable_statistics (direction,currency_code,generated_date,status,tenant_id),
@@ -8461,6 +8464,12 @@ CREATE TABLE wh_document_sequence (
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
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));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
USE baiyue_lastleg;
-- 现场扫描清单独立于客户选箱快照;重复执行不改现有业务数据。
SET @discard_actual_marks_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_carton_marks_json'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_carton_marks_json JSON NULL COMMENT ''PDA现场实际废弃箱唛清单,与客户选箱快照独立保存''');
PREPARE discard_actual_marks_stmt FROM @discard_actual_marks_ddl;
EXECUTE discard_actual_marks_stmt;
DEALLOCATE PREPARE discard_actual_marks_stmt;
+76 -262
View File
@@ -1,5 +1,5 @@
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
-- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。
-- 海外费用资金、仓租四表、审核人名称快照及简化采购六表均在本文件最终建表定义中维护。
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
@@ -917,7 +917,7 @@ CREATE TABLE ll_return_carton_stock_receipt (
CREATE TABLE ll_finance_fee_command (
id bigint 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 '操作幂等键',
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256',
source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID',
@@ -2060,53 +2060,6 @@ CREATE TABLE ll_outbound_pda_task (
UNIQUE KEY uk_ll_outbound_pda_complete_key (customer_id,completion_idempotency_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务';
CREATE TABLE ll_overseas_procurement_fee (
purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定',
confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作',
fee_version INT NULL COMMENT '同用途费用确认版本',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本',
determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用',
original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD',
original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额',
exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照',
exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份',
business_date DATE NULL COMMENT '实际费用业务日期',
wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录',
wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用',
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
id bigint NOT NULL COMMENT '主键ID',
order_id bigint NOT NULL COMMENT '采购订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
fee_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用业务号',
fee_item_code varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项编码',
fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称',
amount decimal(20,2) NOT NULL COMMENT 'CNY金额',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种',
voucher_file_id bigint NULL COMMENT '凭证文件ID',
voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NULL COMMENT '凭证文件名',
remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明',
wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态',
wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码',
wallet_error_message varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明',
wallet_command_event_id varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID',
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount >= 0)),
CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期';
CREATE TABLE ll_overseas_procurement_file (
id bigint NOT NULL COMMENT '主键ID',
@@ -2152,6 +2105,8 @@ CREATE TABLE ll_overseas_procurement_order (
created_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 '审核备注',
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_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
@@ -3977,245 +3932,33 @@ CREATE TABLE ll_return_carton_count_session (
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
CREATE TABLE ll_procurement_wallet_attempt (
id BIGINT NOT NULL COMMENT '请求尝试ID',
order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID',
attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号',
operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款',
business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号',
amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空',
command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用',
status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败',
result_hash CHAR(64) NULL COMMENT '原业务回执摘要',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用',
error_code VARCHAR(64) NULL COMMENT '业务失败码',
error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间',
completed_at DATETIME(3) NULL COMMENT '最终结果时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id),
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
KEY idx_procurement_attempt_order(customer_id,order_id,status),
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。
CREATE TABLE ll_procurement_flow (
order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
purchased_at DATETIME(3) NULL COMMENT '真实购买时间',
purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品',
purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格',
purchased_quantity BIGINT NULL COMMENT '实际购买件数',
purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额',
purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD',
purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用',
purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明',
purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功',
actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID',
actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照',
actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照',
overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间',
packing_version INT NULL COMMENT '当前确认装箱清单版本',
overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间',
domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间',
charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间',
customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间',
domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人',
domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话',
domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址',
cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间',
closed_at DATETIME(3) NULL COMMENT '异常关闭时间',
control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
CREATE TABLE ll_procurement_command (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作',
idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据',
detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
CREATE TABLE ll_procurement_budget_authorization (
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种',
authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种',
voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据',
reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间',
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。
CREATE TABLE ll_procurement_receipt (
id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内',
source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓',
good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量',
received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实',
supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
CREATE TABLE ll_procurement_disposition (
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源',
receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货',
quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实',
voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
CREATE TABLE ll_procurement_manifest (
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代',
confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
CREATE TABLE ll_procurement_carton (
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米',
width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米',
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
CREATE TABLE ll_procurement_carton_source (
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
CREATE TABLE ll_procurement_shipment (
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户',
manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间',
address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
CREATE TABLE ll_procurement_shipment_item (
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
USE baiyue_lastleg;
-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。
CREATE TABLE ll_procurement_label_set (
id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本',
manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要',
sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照',
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
CREATE TABLE ll_procurement_label_item (
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始',
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
CREATE TABLE ll_procurement_estimate_review (
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
CREATE TABLE ll_procurement_repack (
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米',
height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克',
voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据',
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
USE baiyue_lastleg;
-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。
CREATE TABLE ll_procurement_fee_correction (
id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根',
idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要',
target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种',
exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份',
target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY',
difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录',
voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据',
created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
CREATE TABLE ll_procurement_fee_correction_item (
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
USE baiyue_lastleg;
-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。
CREATE TABLE ll_procurement_control_note (
id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型',
target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约',
voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明',
client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容',
original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
CREATE TABLE ll_procurement_closure (
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证',
financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实',
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
CREATE TABLE ll_procurement_closure_item (
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空',
quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置',
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
USE baiyue_lastleg;
@@ -5106,4 +4849,75 @@ CREATE TABLE ll_storage_rent_preparation (
KEY idx_storage_rent_release (release_status,updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 239 AS initialized_tables;
CREATE TABLE ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
USE baiyue_lastleg;
-- 简化采购:独立采购库存,收货、占用和签收扣减均由订单锁串行控制。
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt (
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
order_id BIGINT NOT NULL COMMENT '采购单主键',
quantity BIGINT NOT NULL COMMENT '本批次数量',
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
received_at DATETIME(3) NOT NULL COMMENT '本批次实收时间',
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
KEY idx_procurement_workflow_receipt (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批收货照片与实收数量';
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound (
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
order_id BIGINT NOT NULL COMMENT '采购单主键',
outbound_no VARCHAR(64) NOT NULL COMMENT '采购出库单号',
quantity BIGINT NOT NULL COMMENT '本批次数量',
tracking_no VARCHAR(255) NOT NULL COMMENT '物流跟踪号',
status VARCHAR(32) NOT NULL COMMENT '批次运输派送及签收状态',
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
created_at DATETIME(3) NOT NULL COMMENT '记录创建时间',
completed_at DATETIME(3) NULL COMMENT '本批次签收完成时间',
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
UNIQUE KEY uk_procurement_workflow_outbound_no (outbound_no),
KEY idx_procurement_workflow_outbound (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批出库物流与签收凭证';
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
order_id BIGINT NOT NULL COMMENT '采购单主键',
command_key VARCHAR(128) NOT NULL COMMENT '本单操作幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '动作及请求参数哈希',
operator_id VARCHAR(128) NOT NULL COMMENT '作业人标识',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
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
@@ -7,13 +7,15 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory {
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_EXCEPTION_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.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),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -90,7 +92,7 @@ enum BusinessFileCategory {
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
}
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
}
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
@@ -7,7 +7,7 @@ info:
复用InternalBusinessFileController,不增加公网File路径。
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
装车资料接受非空JPG/PNG/PDF(Lastleg仅允许司机驾驶证类别使用PDF),油耗仍仅接受JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
@@ -0,0 +1,88 @@
openapi: 3.0.3
info:
title: 车辆与司机档案照片内部文件契约
version: 1.0.0
description: >-
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_VEHICLE_PHOTO或FLEET_DRIVER_PHOTO、
businessId=对应车辆或司机内部ID。Lastleg负责档案存在性、列表/维护权限、版本关联和历史可见性;
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅JPG/PNG。
上传只生成不可变对象,不改变驾驶证审核结论,不生成公开URL,排车保存时复制为本趟证据,后续档案替换不改历史排车。
security:
- InternalServiceToken: []
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
post:
summary: 上传车辆或司机档案照片
parameters:
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
'200': {$ref: '#/components/responses/Metadata'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
summary: 校验归属并读取元数据
responses:
'200': {$ref: '#/components/responses/Metadata'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
summary: 校验归属并下载真实文件
responses:
'200':
description: 文件内容,业务错误按JSON code识别
content:
image/jpeg: {schema: {type: string, format: binary}}
image/png: {schema: {type: string, format: binary}}
'403': {description: 内部服务令牌无效}
components:
securitySchemes:
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
parameters:
TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}}
BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}}
BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
Metadata:
description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string, nullable: true}
data:
type: object
properties:
fileId: {type: string}
tenantId: {type: string, enum: ['0']}
businessType: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}
businessId: {type: string}
fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [image/jpeg, image/png]}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
@@ -22,6 +22,12 @@ import static org.mockito.Mockito.when;
class BusinessFileStorageTest {
@TempDir Path root;
@Test void loadingLicenseStoresPdfButFuelStillRejectsPdf() {
var file=new MockMultipartFile("file","license.pdf","application/pdf","%PDF-1.4\n%%EOF".getBytes());
var stored=storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_LOADING_PHOTO);
assertThat(stored.contentType()).isEqualTo("application/pdf");
assertThatThrownBy(()->storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_FUEL_PHOTO)).isInstanceOf(BizException.class);
}
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
BusinessFileStorage storage = storage();
@@ -0,0 +1,16 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class FleetResourcePhotoCategoryTest {
@Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() {
for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) {
category.validateOwner(0);
assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台");
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024);
assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class);
}
}
}
@@ -45,8 +45,9 @@ class OwnedBusinessPhotoFileTest {
verifyNoInteractions(files, storage);
}
/** 装车驾驶证允许 PDF;油耗仍限图片,两类都拒绝空文件、伪造内容和超限文件。 */
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
void enforcesLoadingAndFuelFormatPoliciesAndRejectsInvalidFiles(String type) throws Exception {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
@@ -60,8 +61,14 @@ class OwnedBusinessPhotoFileTest {
for (var upload : new MockMultipartFile[]{
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
new MockMultipartFile("file", "spoof.pdf", "application/pdf", "not-pdf".getBytes())})
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nTEST license".getBytes());
if (LOADING.equals(type)) {
var saved = storage.store(0, "71", pdf, category);
assertThat(saved.contentType()).isEqualTo("application/pdf");
assertThat(storage.read(saved.objectKey())).isEqualTo(pdf.getBytes());
} else assertThatThrownBy(() -> storage.store(0, "71", pdf, category)).isInstanceOf(BizException.class);
MultipartFile oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
@@ -0,0 +1,4 @@
package com.baiyue.finance.dto;
/** 接收事务锁定的来源版本及其不可变客户归属。 */
public record BusinessFeeSourceVersion(int sourceVersion, long customerId) { }
@@ -0,0 +1,27 @@
package com.baiyue.finance.mapper;
import com.baiyue.finance.dto.BusinessFeeSourceVersion;
import org.apache.ibatis.annotations.Arg;
import org.apache.ibatis.annotations.ConstructorArgs;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** 费用接收时锁定稳定来源,防止新版本改写其他客户的费用。 */
@Mapper
public interface BusinessFeeSourceMapper {
/** 使用完整来源键锁定全部版本;锁定读取必须绕过缓存并参与接收事务。 */
@Select("SELECT source_fee_version,customer_id FROM fin_fee_source "
+ "WHERE source_system=#{system} AND source_type=#{type} AND source_fee_id=#{feeId} "
+ "ORDER BY source_fee_version FOR UPDATE")
@ConstructorArgs({
@Arg(column = "source_fee_version", javaType = int.class),
@Arg(column = "customer_id", javaType = long.class)
})
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<BusinessFeeSourceVersion> lockVersions(@Param("system") String system,
@Param("type") String type, @Param("feeId") long feeId);
}
@@ -3,14 +3,23 @@ package com.baiyue.finance.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Options;
import java.util.List;
import java.util.Map;
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
@Mapper
public interface FinanceFeeSourceReadMapper {
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))";
String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id" +
" WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status&lt;&gt;'VOIDED'))";
// 应付进度取同客户的有效结算单;作废单释放来源,直接已付使用来源中的付款事实。
String PAYABLE_STATUS = "(SELECT CASE s.payment_status WHEN 'PAID' THEN 'PAID' WHEN 'PARTIALLY_PAID' THEN 'PARTIALLY_PAID'" +
" ELSE 'PAYABLE_BILLED' END FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id" +
" WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status&lt;&gt;'VOIDED' ORDER BY s.id DESC LIMIT 1)";
String STATUS = "CASE WHEN f.status IN ('VOIDED','SUPERSEDED','SETTLED') THEN f.status" +
" WHEN f.direction='PAYABLE' THEN COALESCE(" + PAYABLE_STATUS + ",CASE WHEN f.settlement_mode='DIRECT_PAID'" +
" AND f.review_status='APPROVED' AND f.paid_at IS NOT NULL THEN 'PAID' ELSE f.status END)" +
" WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
@@ -41,6 +50,13 @@ public interface FinanceFeeSourceReadMapper {
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<Map<String, Object>> list(Map<String, Object> filters);
/** 详情与列表共用状态投影,仍允许通过精确 ID 查看历史版本。 */
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS +
" effective_status FROM fin_fee_source f WHERE f.id=#{id}</script>")
// 同事务的审核/调整仍含 JDBC 写入,读取必须清空 MyBatis 会话缓存才能返回变更后状态。
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
Map<String, Object> detail(@Param("id") long id);
String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" +
" AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" +
" AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" +
@@ -0,0 +1,52 @@
package com.baiyue.finance.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
/** 结算状态变更与重新占用来源的事务锁;锁定读取不得复用会话缓存。 */
@Mapper
public interface FinanceSettlementStateMapper {
/** 锁定待作废或重开的结算,保留客户与来源关联。 */
@Select("SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version,direction,currency_code,supplier_id,reconciliation_statement_id FROM fin_settlement WHERE id=#{settlementId} FOR UPDATE")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<Map<String, Object>> lockSettlement(@Param("settlementId") long settlementId);
/** 直接锁定来源表,不能依赖外连接右侧记录的锁语义。 */
@Select("SELECT f.id FROM fin_fee_source f WHERE EXISTS (SELECT 1 FROM fin_settlement_line sl WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} AND sl.fee_source_id=f.id) ORDER BY f.id FOR UPDATE")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<Long> lockSourceRows(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
/** 按来源顺序锁定原单全部明细,缺失或跨客户来源仍返回明细供服务拒绝。 */
@Select("SELECT sl.fee_source_id,sl.amount line_amount,sl.currency_code line_currency,f.id,f.customer_id,f.direction,f.currency_code,f.amount,f.review_status,f.status,f.settlement_mode,f.source_snapshot FROM fin_settlement_line sl LEFT JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.fee_source_id FOR UPDATE")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<Map<String, Object>> lockSources(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
/** 使用当前锁定读检查其他有效结算,避免等待来源锁后读取旧快照。 */
@Select("SELECT s.id FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=#{customerId} AND sl.fee_source_id=#{sourceId} AND s.id<>#{settlementId} AND s.status<>'VOIDED' FOR UPDATE")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<Map<String, Object>> lockOtherSourceSettlements(@Param("customerId") long customerId, @Param("sourceId") long sourceId, @Param("settlementId") long settlementId);
/** 与应收新建共同锁定原对账单,防止检查后被调整或再次生成应收。 */
@Select("SELECT id,tenant_id,status,currency_code,total_amount,received_amount FROM fin_bill WHERE tenant_id=#{customerId} AND id=#{billId} FOR UPDATE")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<Map<String, Object>> lockBill(@Param("customerId") long customerId, @Param("billId") long billId);
/** 读取原账单当前明细,重开不能沿用调账前的行金额。 */
@Select("SELECT source_id fee_source_id,amount line_amount,currency_code line_currency FROM fin_bill_line WHERE tenant_id=#{customerId} AND bill_id=#{billId} ORDER BY source_id FOR UPDATE")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<Map<String, Object>> lockBillLines(@Param("customerId") long customerId, @Param("billId") long billId);
/** 锁定同账单其他有效应收,作废旧单不可绕过唯一占用。 */
@Select("SELECT id FROM fin_settlement WHERE tenant_id=#{customerId} AND direction='RECEIVABLE' AND reconciliation_statement_id=#{billId} AND id<>#{settlementId} AND status<>'VOIDED' FOR UPDATE")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<Map<String, Object>> lockOtherBillSettlements(@Param("customerId") long customerId, @Param("billId") long billId, @Param("settlementId") long settlementId);
/** 客户范围内按原版本推进状态。 */
@Update("UPDATE fin_settlement SET status=#{status},updated_by=#{operatorId},version=version+1 WHERE id=#{settlementId} AND tenant_id=#{customerId} AND version=#{version}")
int changeStatus(@Param("status") String status, @Param("operatorId") long operatorId, @Param("settlementId") long settlementId, @Param("customerId") long customerId, @Param("version") int version);
}
@@ -0,0 +1,20 @@
package com.baiyue.finance.repository;
import com.baiyue.finance.dto.BusinessFeeSourceVersion;
import com.baiyue.finance.mapper.BusinessFeeSourceMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 为接收服务提供同一事务中的费用来源版本与归属事实。 */
@Repository
@RequiredArgsConstructor
public class BusinessFeeSourceRepository {
private final BusinessFeeSourceMapper mapper;
/** 原来源的客户不能被新版本替换,查询范围不得按传入客户提前过滤。 */
public List<BusinessFeeSourceVersion> lockVersions(String system, String type, long feeId) {
return mapper.lockVersions(system, type, feeId);
}
}
@@ -1,6 +1,7 @@
package com.baiyue.finance.repository;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
import com.baiyue.finance.service.FinanceEventException;
@@ -51,6 +52,21 @@ public class FinanceFeeSourceReadRepository {
return mapper.list(filters).stream().map(this::summary).toList();
}
/** 精确版本详情使用与列表相同的金额和状态,并保留审核信息及操作记录。 */
public FeeSourceDetail detail(long id) {
Map<String, Object> raw = mapper.detail(id);
if (raw == null || raw.isEmpty()) throw new FinanceEventException("FIN-FEE-404", "费用来源不存在");
Map<String, Object> row = row(raw);
FeeSourceSummary s = summary(row);
return new FeeSourceDetail(s.feeSourceId(), s.sourceSystem(), s.sourceType(), s.sourceBusinessId(), s.sourceFeeId(),
s.sourceVersion(), s.direction(), s.customerId(), s.customerName(), s.supplierId(), s.supplierName(), s.orderId(),
s.inboundId(), s.inboundCode(), s.containerId(), s.containerNo(), s.businessDate(), s.feeItemName(),
s.quantity(), s.quantityUnit(), s.unitPrice(), s.periodStart(), s.periodEnd(), s.amount(), s.currency(),
s.originalCurrency(), s.originalAmount(), s.exchangeRate(), s.exchangeRateMonth(), s.reviewStatus(),
s.sourceStatus(), s.statementGenerated(), text(row.get("review_reason")), text(row.get("source_hash")),
((Number) row.get("operation_version")).intValue(), logs(s.customerId(), id));
}
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
public List<OperationLog> logs(long customerId, long id) {
List<OperationLog> result = new ArrayList<>();
@@ -0,0 +1,36 @@
package com.baiyue.finance.repository;
import com.baiyue.finance.mapper.FinanceSettlementStateMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.LinkedCaseInsensitiveMap;
import java.util.List;
import java.util.Map;
/** 封装结算状态及原始费用、账单的事务访问。 */
@Repository
public class FinanceSettlementStateRepository {
private final FinanceSettlementStateMapper mapper;
/** 注入结算状态持久化入口。 */
public FinanceSettlementStateRepository(FinanceSettlementStateMapper mapper) { this.mapper = mapper; }
/** 统一数据库返回的列名大小写。 */
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
return rows.stream().map(row -> { Map<String, Object> value = new LinkedCaseInsensitiveMap<>(); value.putAll(row); return value; }).toList();
}
/** 锁定目标结算。 */
public List<Map<String, Object>> lockSettlement(long id) { return rows(mapper.lockSettlement(id)); }
/** 锁定结算所有费用来源。 */
public List<Map<String, Object>> lockSources(long customerId, long id) {
mapper.lockSourceRows(customerId, id);
return rows(mapper.lockSources(customerId, id));
}
/** 判断费用是否被其他有效结算占用。 */
public boolean sourceOccupied(long customerId, long sourceId, long id) { return !mapper.lockOtherSourceSettlements(customerId, sourceId, id).isEmpty(); }
/** 锁定原始对账单。 */
public List<Map<String, Object>> lockBill(long customerId, long billId) { return rows(mapper.lockBill(customerId, billId)); }
/** 锁定原始对账单的当前明细。 */
public List<Map<String, Object>> lockBillLines(long customerId, long billId) { return rows(mapper.lockBillLines(customerId, billId)); }
/** 判断原对账单是否已生成其他有效应收。 */
public boolean billOccupied(long customerId, long billId, long id) { return !mapper.lockOtherBillSettlements(customerId, billId, id).isEmpty(); }
/** 按客户及版本变更结算状态。 */
public int changeStatus(String status, long operatorId, long id, long customerId, int version) { return mapper.changeStatus(status, operatorId, id, customerId, version); }
}
@@ -6,6 +6,7 @@ import com.baiyue.common.event.DeliveryAck;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.finance.repository.BusinessFeeSourceRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
@@ -13,11 +14,13 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Consumes complete overseas business fee snapshots without a cross-region synchronous callback. */
/** 接收海外费用不可变快照,在同一事务内完成来源版本与接收记录。 */
@Service
@RequiredArgsConstructor
public class BusinessFeeSourceEventService {
@@ -26,7 +29,9 @@ public class BusinessFeeSourceEventService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceSupplierValidator supplierValidator;
private final BusinessFeeSourceRepository sources;
/** 先核对不可变事件,再恢复已提交结果;仅新事件检查外部主档。 */
@Transactional
public DeliveryAck consume(EventEnvelope envelope) {
try {
@@ -56,6 +61,8 @@ public class BusinessFeeSourceEventService {
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败");
}
// 回执丢失后的原事件重放不能依赖供应商当前是否可用,否则已入账事件无法确认。
validateSupplier(source);
jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')",
envelope.eventId(), sourceSystem, envelope.eventType(), eventJson, envelope.payloadHash());
long result = apply(sourceSystem, source);
@@ -64,6 +71,7 @@ public class BusinessFeeSourceEventService {
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
}
/** 锁定同一来源的历史版本,拒绝跨客户替代,并保留已出账来源冻结规则。 */
private long apply(String system, BusinessFeeSourceChangedV1 source) {
long feeId = positive(source.sourceFeeId(), "sourceFeeId");
List<Map<String, Object>> same = jdbc.queryForList(
@@ -83,10 +91,12 @@ public class BusinessFeeSourceEventService {
}
if ("VOIDED".equals(source.action()))
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
List<Map<String, Object>> versions = jdbc.queryForList(
"SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE",
system, source.sourceType(), feeId);
if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion()))
var versions = sources.lockVersions(system, source.sourceType(), feeId);
long customerId = positive(source.customerId(), "customerId");
// 来源身份跨版本保持客户归属;检查失败时 Inbox 插入也随当前事务回滚。
if (versions.stream().anyMatch(row -> row.customerId() != customerId))
throw error("FIN-EVT-018", "费用来源客户归属不允许变更");
if (versions.stream().anyMatch(row -> row.sourceVersion() > source.sourceVersion()))
throw error("FIN-EVT-008", "费用来源版本已过期");
requireSourceNotFrozen(system, source.sourceType(), feeId);
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'",
@@ -110,6 +120,7 @@ public class BusinessFeeSourceEventService {
return id;
}
/** 校验载荷自身的身份、金额和摘要,不依赖会随时间变化的外部数据。 */
private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) {
if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem())
|| "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType())
@@ -130,6 +141,7 @@ public class BusinessFeeSourceEventService {
}
positive(value.sourceFeeId(), "sourceFeeId");
positive(value.customerId(), "customerId");
validateAmounts(value);
if (value.walletSettlement() != null) {
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
@@ -149,10 +161,8 @@ public class BusinessFeeSourceEventService {
throw error("FIN-EVT-015", "费用来源尚未发布或已失效");
}
if ("PAYABLE".equals(value.direction())) {
long supplierId = positive(value.supplierId(), "supplierId");
var supplier = supplierValidator.requireActive(supplierId, trim(value.supplierCategory()));
if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name()))
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
positive(value.supplierId(), "supplierId");
if (blank(value.supplierName())) throw error("FIN-EVT-012", "应付来源供应商快照不完整");
validateSettlement(value);
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|| value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null
@@ -174,6 +184,31 @@ public class BusinessFeeSourceEventService {
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
/** 使用来源冻结汇率复核人民币金额,与海外生产端的分位四舍五入口径一致。 */
private void validateAmounts(BusinessFeeSourceChangedV1 value) {
try {
// 不在接收端静默修正金额或截断汇率;无效精度须由生产端重新审核。
value.originalAmount().setScale(2, RoundingMode.UNNECESSARY);
value.totalAmount().setScale(2, RoundingMode.UNNECESSARY);
value.exchangeRate().setScale(6, RoundingMode.UNNECESSARY);
} catch (ArithmeticException invalidPrecision) {
throw error("FIN-EVT-019", "费用金额或汇率精度无效");
}
if ("CNY".equals(value.originalCurrency()) && value.exchangeRate().compareTo(BigDecimal.ONE) != 0)
throw error("FIN-EVT-019", "人民币原币费用汇率必须为1");
BigDecimal expected = value.originalAmount().multiply(value.exchangeRate()).setScale(2, RoundingMode.HALF_UP);
if (expected.compareTo(value.totalAmount()) != 0)
throw error("FIN-EVT-019", "费用人民币金额与原币金额、汇率不一致");
}
/** 首次接收应付仍须校验启用供应商与名称快照,依赖不可用时失败关闭。 */
private void validateSupplier(BusinessFeeSourceChangedV1 value) {
if (!"PAYABLE".equals(value.direction())) return;
var supplier = supplierValidator.requireActive(positive(value.supplierId(), "supplierId"), trim(value.supplierCategory()));
if (!value.supplierName().equals(supplier.name()))
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
}
private void validateSettlement(BusinessFeeSourceChangedV1 value) {
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if ("ON_ACCOUNT".equals(value.settlementMode())) {
@@ -0,0 +1,71 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.IamCustomerClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.function.BiFunction;
/** 单据客户名称取真实主档或有效快照,内部引用不能作为名称展示。 */
@Service
public class FinanceCustomerNames {
private final IamCustomerClient customers;
private final String token;
/** 复用已存在的 IAM 客户查询,不跨库读取客户资料。 */
public FinanceCustomerNames(IamCustomerClient customers,
@Value("${baiyue.security.internal-service-token}") String token) {
this.customers = customers;
this.token = token;
}
/** 新建单据必须取得真实客户名称,依赖故障或客户不存在时停止建单。 */
public String required(long customerId) {
String name = lookup(customerId);
if (name == null) throw new FinanceEventException("FIN-CUSTOMER-404", "客户名称未提供,无法生成财务单据");
return name;
}
/** 应收结算沿用有效的对账单名称,缺失时必须取得真实主档后才能建单。 */
public String requiredSnapshot(long customerId, String snapshot) {
return valid(customerId, snapshot) ? snapshot.trim() : required(customerId);
}
/** 每次列表/详情请求内复用同客户查询结果,保留有效的历史名称快照。 */
public BiFunction<Long, String, String> resolver() {
var names = new HashMap<Long, String>();
return (customerId, snapshot) -> {
if (valid(customerId, snapshot)) return snapshot.trim();
return names.computeIfAbsent(customerId, id -> {
String name = lookup(id);
return name == null ? "—" : name;
});
};
}
/** 仅接受所请求客户的主档,不能拿其他客户记录填充当前单据。 */
private String lookup(long customerId) {
try {
var response = customers.page(token, 1, 1, null, null, customerId);
if (response == null || !response.successful() || response.data() == null || response.data().records() == null)
throw new FinanceEventException("FIN_503", "客户主数据暂不可用");
var rows = response.data().records();
if (rows.isEmpty()) return null;
if (rows.size() != 1 || rows.get(0).id() != customerId)
throw new FinanceEventException("FIN_503", "客户主数据返回范围异常");
String name = rows.get(0).tenantName();
return valid(customerId, name) ? name.trim() : null;
} catch (FinanceEventException failure) {
throw failure;
} catch (RuntimeException failure) {
throw new FinanceEventException("FIN_503", "客户主数据暂不可用");
}
}
/** 空名称与内部编号占位不是有效的业务名称。 */
private static boolean valid(long customerId, String name) {
return name != null && !name.isBlank() && !name.trim().equals("—")
&& !name.trim().equals(Long.toString(customerId)) && !name.trim().matches("客户\\s*(?:ID)?\\s*\\d+");
}
}
@@ -7,13 +7,12 @@ import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
@@ -199,41 +198,16 @@ public class FinanceExportProviderService {
.map(value -> receivableStatisticsRow(month, value)).toList();
}
/** 导出与列表使用相同当前版本、状态及筛选,已付款来源不能被可选结算池遗漏。 */
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String direction = text(p, "direction");
String status = text(p, "status");
String reviewStatus = text(p, "reviewStatus");
Boolean generated = bool(p, "statementGenerated");
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
String keyword = lower(text(p, "keyword"));
List<Map<String, Object>> rows = new ArrayList<>();
for (FeePoolItem item : queryService.feePool(customerId, direction)) {
FeeSourceDetail detail = detail(item);
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
String actualReview = detail == null ? null : detail.reviewStatus();
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
LocalDate businessDate = detail == null || detail.businessDate() == null
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
if (generated != null && !generated.equals(actualGenerated)) continue;
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()),
detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo())));
if (keyword != null && !searchable.contains(keyword)) continue;
rows.add(feeRow(item, detail, businessDate));
for (int page = 1; ; page++) {
var result = operationService.listFinanceFeeSources(page, 200, longValue(p, "customerId", false),
text(p, "direction"), text(p, "status"), text(p, "reviewStatus"), bool(p, "statementGenerated"),
date(p, "businessDateFrom"), date(p, "businessDateTo"), text(p, "keyword"));
result.data().forEach(source -> rows.add(feeRow(source)));
if ((long) page * 200 >= result.page().total()) return rows;
}
return rows;
}
private FeeSourceDetail detail(FeePoolItem item) {
if ("MANUAL_COST".equals(item.sourceType())) return null;
String[] parts = item.poolItemId().split(":", -1);
if (parts.length != 2) return null;
try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); }
catch (RuntimeException ignored) { return null; }
}
private List<Map<String, Object>> statementRows(Map<String, Object> p) {
@@ -376,13 +350,12 @@ public class FinanceExportProviderService {
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
}
private Map<String, Object> feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) {
return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(),
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
"reviewStatus", detail == null ? null : detail.reviewStatus(),
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
private Map<String, Object> feeRow(FeeSourceSummary source) {
return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(),
"sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
"containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
"quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(),
"reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated());
}
private Map<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
@@ -178,7 +178,7 @@ public class FinancePhase1OperationService {
String normalizedReview = normalized(reviewStatus,
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "PAYABLE_BILLED", "PARTIALLY_PAID", "PAID", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
@@ -186,9 +186,7 @@ public class FinancePhase1OperationService {
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效");
List<FeeSourceDetail> rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId);
if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在");
return rows.get(0);
return feeSources.detail(feeSourceId);
}
@Transactional
@@ -225,6 +223,7 @@ public class FinancePhase1OperationService {
adjustment ? "POST" : "PUT", path, request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可编辑或调整");
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
@@ -302,6 +301,7 @@ public class FinancePhase1OperationService {
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作");
if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可操作");
if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
int changed = voidSource
? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
@@ -315,27 +315,6 @@ public class FinancePhase1OperationService {
return after;
}
/** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
JsonNode snapshot = parse(rs.getString("source_snapshot"));
long id = rs.getLong("id");
boolean generated = Boolean.TRUE.equals(jdbc.queryForObject("SELECT COUNT(*)>0 FROM fin_bill_line WHERE source_id=? AND source_version=?", Boolean.class, id, rs.getInt("source_fee_version")));
String status = rs.getString("status");
if (generated && "AVAILABLE".equals(status)) status = "BILLED";
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"),
quantity != null && quantity.signum() > 0 ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id));
}
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key");
String hash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
@@ -40,10 +40,12 @@ public class FinanceQueryService {
private static final String CNY = "CNY";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceCustomerNames customerNames;
public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceCustomerNames customerNames) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.customerNames = customerNames;
}
public CustomerAccount customerAccount(long customerId) {
@@ -131,12 +133,13 @@ public class FinanceQueryService {
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId);
var names = customerNames.resolver();
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
@@ -147,6 +150,7 @@ public class FinanceQueryService {
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
validateOptionalCustomer(customerId);
var names = customerNames.resolver();
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.customer_confirmation_status,b.customer_confirmed_by,b.customer_confirmed_by_name,b.customer_confirmed_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
"COALESCE(p.credit_days,30) credit_days," +
@@ -163,7 +167,7 @@ public class FinanceQueryService {
Object[] args = new Object[0];
if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + " ORDER BY b.bill_date DESC,b.id DESC", (rs, rowNum) -> new ReconciliationSummary(
rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"),
rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")),
date(rs, "period_start"), date(rs, "period_end"), rs.getInt("credit_days"), rs.getString("currency_code"),
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
@@ -178,9 +182,10 @@ public class FinanceQueryService {
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
var names = customerNames.resolver();
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
@@ -219,6 +224,7 @@ public class FinanceQueryService {
row.outstandingAmount(), row.currency(), row.status(), row.version())).toList();
}
/** 成本查询按来源付款事实回显金额,直接已付不能再次展示为待付款。 */
public List<PayableSettlement> payableSettlements(Long customerId) {
validateOptionalCustomer(customerId);
String sql = "SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE 1=1";
@@ -227,9 +233,13 @@ public class FinanceQueryService {
return jdbc.query(sql + " ORDER BY created_at DESC,id DESC",
(rs, rowNum) -> {
BigDecimal amount = rs.getBigDecimal("cost_amount");
boolean directPaid = "SOURCE_DIRECT_PAID".equals(rs.getString("cost_type"));
// 已确认直付成本已全额付款;作废历史不再计入有效已付,也不形成新应付。
BigDecimal paid = directPaid && "CONFIRMED".equals(rs.getString("status")) ? amount : BigDecimal.ZERO;
BigDecimal outstanding = directPaid ? BigDecimal.ZERO : amount;
return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"),
rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"),
rs.getString("fee_item_name"), amount, paid, outstanding, rs.getString("currency_code"), rs.getString("status"),
time(rs, "created_at"), rs.getInt("version"));
}, args);
}
@@ -52,19 +52,24 @@ public class FinanceSettlementService {
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
private final FinanceDocumentNumberGenerator documentNumbers;
private final FinanceCustomerNames customerNames;
private final com.baiyue.finance.repository.FinanceSettlementStateRepository states;
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
@Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator,
FinanceDocumentNumberGenerator documentNumbers,
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames,
com.baiyue.finance.repository.FinanceSettlementStateRepository states) {
this.jdbc = jdbc;
this.details = details;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
this.documentNumbers = documentNumbers;
this.customerNames = customerNames;
this.states = states;
}
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
@@ -87,18 +92,16 @@ public class FinanceSettlementService {
Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST",
"/api/platform/finance/v1/receivable-settlements", request);
if (replay != null) return getSettlement(replay);
Integer occupied = jdbc.queryForObject(
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' " +
"AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class,
customerId, request.reconciliationStatementId());
if (occupied != null && occupied > 0) throw error("FIN_409", "对账单已生成有效应收结算单");
// 新建与重开共用当前锁定读,等待来源锁后不能沿用事务开始时的占用快照。
if (states.billOccupied(customerId, request.reconciliationStatementId(), 0))
throw error("FIN_409", "对账单已生成有效应收结算单");
Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",
Integer.class, customerId, request.reconciliationStatementId());
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE",
text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(),
customerNames.requiredSnapshot(customerId, text(bill, "customer_name_snapshot")), null, null, request.reconciliationStatementId(),
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
generatedDate, date(bill, "due_date"), operatorId);
int inserted = jdbc.update(
@@ -146,17 +149,14 @@ public class FinanceSettlementService {
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
}
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
Integer used = jdbc.queryForObject(
"SELECT COUNT(*) FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id " +
"WHERE sl.fee_source_id=? AND s.status<>'VOIDED'", Integer.class, number(source, "id").longValue());
if (used != null && used > 0) throw error("FIN_409", "费用来源已被有效结算单占用");
if (states.sourceOccupied(customerId, number(source, "id").longValue(), 0))
throw error("FIN_409", "费用来源已被有效结算单占用");
}
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
String supplierCategory = supplierCategoryConstraint(sources);
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name();
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
String customerName = customerNames.required(customerId);
LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
@@ -176,7 +176,7 @@ public class FinanceSettlementService {
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
List<Map<String, Object>> records = details.summary(settlementId);
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
SettlementSummary summary = summary(records.get(0));
SettlementSummary summary = summary(records.get(0), customerNames.resolver());
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
@@ -206,7 +206,8 @@ public class FinanceSettlementService {
args.add(customerId);
}
sql += " ORDER BY s.generated_date DESC,s.id DESC";
return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList();
var names = customerNames.resolver();
return jdbc.queryForList(sql, args.toArray()).stream().map(row -> summary(row, names)).toList();
}
@Transactional
@@ -247,13 +248,14 @@ public class FinanceSettlementService {
return getSettlement(settlementId);
}
/** 作废保留来源关联;重开必须重新校验并锁定当前仍可占用的原始依据。 */
@Transactional
public SettlementDetail actionSettlement(long settlementId, long operatorId, String key,
SettlementActionRequest request) {
validateSettlementAction(request);
Map<String, Object> settlement = one(
"SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " +
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
List<Map<String, Object>> locked = states.lockSettlement(settlementId);
if (locked.isEmpty()) throw error("FIN_404", "结算单不存在");
Map<String, Object> settlement = locked.get(0);
long customerId = number(settlement, "tenant_id").longValue();
Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST",
"/api/platform/finance/v1/settlements/" + settlementId + "/actions", request);
@@ -272,11 +274,10 @@ public class FinanceSettlementService {
if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开");
}
requireReopenableSources(settlement);
targetStatus = "ISSUED";
}
int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " +
"WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId,
request.expectedVersion());
int changed = states.changeStatus(targetStatus, operatorId, settlementId, customerId, request.expectedVersion());
if (changed != 1) throw error("FIN_409", "结算单版本冲突");
audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement),
settlementStateSnapshot(settlement, targetStatus), operatorId);
@@ -284,6 +285,77 @@ public class FinanceSettlementService {
return getSettlement(settlementId);
}
/** 重开沿用原单金额,来源变化必须重新生成结算,不覆盖历史单据。 */
private void requireReopenableSources(Map<String, Object> settlement) {
long customerId = number(settlement, "tenant_id").longValue();
long settlementId = number(settlement, "id").longValue();
if (!CNY.equals(text(settlement, "currency_code")) || decimal(settlement, "total_amount").signum() <= 0)
throw error("FIN_409", "原结算金额或币种已不满足重开条件");
if ("RECEIVABLE".equals(text(settlement, "direction"))) {
requireReopenableReceivable(settlement);
return;
}
if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "结算方向不支持重开");
Long supplierId = nullableNumber(settlement, "supplier_id");
List<Map<String, Object>> sources = states.lockSources(customerId, settlementId);
if (supplierId == null || sources.isEmpty()) throw error("FIN_409", "原应付来源或供应商不完整");
BigDecimal total = BigDecimal.ZERO;
List<Map<String, Object>> categories = new ArrayList<>();
for (Map<String, Object> source : sources) {
JsonNode snapshot = parseTree(text(source, "source_snapshot"));
if (source.get("id") == null || number(source, "customer_id").longValue() != customerId
|| !"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|| !"APPROVED".equals(text(source, "review_status")) || !"ON_ACCOUNT".equals(text(source, "settlement_mode"))
|| !CNY.equals(text(source, "currency_code")) || !CNY.equals(text(source, "line_currency"))
|| !supplierId.equals(jsonLong(snapshot, "supplierId")) || decimal(source, "amount").signum() <= 0
|| decimal(source, "amount").compareTo(decimal(source, "line_amount")) != 0)
throw error("FIN_409", "原应付费用已调整、作废或不再满足结算条件,请重新生成结算单");
if (states.sourceOccupied(customerId, number(source, "fee_source_id").longValue(), settlementId))
throw error("FIN_409", "原应付费用已被其他有效结算单占用");
total = total.add(decimal(source, "line_amount"));
Map<String, Object> category = new java.util.HashMap<>();
category.put("supplier_category", jsonText(snapshot, "supplierCategory"));
categories.add(category);
}
if (total.compareTo(decimal(settlement, "total_amount")) != 0)
throw error("FIN_409", "原应付结算与费用金额不一致,请重新生成结算单");
supplierValidator.requireActive(supplierId, supplierCategoryConstraint(categories));
}
/** 应收重开还须核对原账单当前状态及逐行金额,防止调账后恢复旧应收。 */
private void requireReopenableReceivable(Map<String, Object> settlement) {
long customerId = number(settlement, "tenant_id").longValue();
long settlementId = number(settlement, "id").longValue();
Long billId = nullableNumber(settlement, "reconciliation_statement_id");
if (billId == null) throw error("FIN_409", "原应收缺少对账单关联");
List<Map<String, Object>> bills = states.lockBill(customerId, billId);
if (bills.isEmpty()) throw error("FIN_409", "原对账单不存在或不属于当前客户");
Map<String, Object> bill = bills.get(0);
if (!"ISSUED".equals(text(bill, "status")) || !CNY.equals(text(bill, "currency_code"))
|| decimal(bill, "received_amount").signum() != 0
|| decimal(bill, "total_amount").compareTo(decimal(settlement, "total_amount")) != 0)
throw error("FIN_409", "原对账单状态或金额已变化,请重新生成应收结算单");
if (states.billOccupied(customerId, billId, settlementId))
throw error("FIN_409", "原对账单已生成其他有效应收结算单");
List<Map<String, Object>> lines = states.lockBillLines(customerId, billId);
List<Map<String, Object>> sources = states.lockSources(customerId, settlementId);
if (lines.isEmpty() || sources.size() != lines.size()) throw error("FIN_409", "原应收明细与对账单不一致");
BigDecimal total = BigDecimal.ZERO;
for (Map<String, Object> source : sources) {
var matching = lines.stream().filter(line -> number(line, "fee_source_id").longValue() == number(source, "fee_source_id").longValue()).toList();
if (source.get("id") == null || number(source, "customer_id").longValue() != customerId
|| !"RECEIVABLE".equals(text(source, "direction")) || !"BILLED".equals(text(source, "status"))
|| !"APPROVED".equals(text(source, "review_status")) || !CNY.equals(text(source, "currency_code"))
|| !CNY.equals(text(source, "line_currency")) || matching.size() != 1
|| !CNY.equals(text(matching.get(0), "line_currency"))
|| decimal(source, "line_amount").compareTo(decimal(matching.get(0), "line_amount")) != 0)
throw error("FIN_409", "原应收费用或明细金额已变化,请重新生成结算单");
total = total.add(decimal(source, "line_amount"));
}
if (total.compareTo(decimal(settlement, "total_amount")) != 0)
throw error("FIN_409", "原应收明细合计与结算金额不一致");
}
private long insertSettlement(long customerId, String no, String direction, String customerName,
Long supplierId, String supplierName, Long reconciliationId,
String businessPeriod, String currency, BigDecimal total,
@@ -419,9 +491,9 @@ public class FinanceSettlementService {
MDC.get("requestId"), MDC.get("traceId"));
}
private SettlementSummary summary(Map<String, Object> row) {
private SettlementSummary summary(Map<String, Object> row, java.util.function.BiFunction<Long, String, String> names) {
return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"),
text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"),
text(row, "direction"), number(row, "tenant_id").longValue(), names.apply(number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot")),
nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"),
nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"),
text(row, "business_period"), text(row, "fee_item_summary"),
@@ -15,9 +15,11 @@ import java.util.List;
public class ReceivableStatisticsService {
private static final String CURRENCY = "CNY";
private final JdbcTemplate jdbc;
private final FinanceCustomerNames customerNames;
public ReceivableStatisticsService(JdbcTemplate jdbc) {
public ReceivableStatisticsService(JdbcTemplate jdbc, FinanceCustomerNames customerNames) {
this.jdbc = jdbc;
this.customerNames = customerNames;
}
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
@@ -61,8 +63,9 @@ public class ReceivableStatisticsService {
args.add(customerId);
}
sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id");
var names = customerNames.resolver();
return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow(
rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY,
rs.getLong("tenant_id"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")), CURRENCY,
rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"),
rs.getBigDecimal("total_outstanding"))), args.toArray());
}
@@ -46,14 +46,16 @@ public class ReconciliationStatementService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceDocumentNumberGenerator documentNumbers;
private final FinanceCustomerNames customerNames;
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
@Autowired
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceDocumentNumberGenerator documentNumbers) {
FinanceDocumentNumberGenerator documentNumbers, FinanceCustomerNames customerNames) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.documentNumbers = documentNumbers;
this.customerNames = customerNames;
}
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
@@ -112,6 +114,7 @@ public class ReconciliationStatementService {
return detail(request.customerId(), statementId);
}
/** 推进对账单状态并保存修订,响应包含本次操作刚写入的审计记录。 */
@Transactional
public ReconciliationDetail action(long operatorId, long statementId, String key, ReconciliationActionRequest request) {
validateAction(statementId, request);
@@ -150,9 +153,11 @@ public class ReconciliationStatementService {
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId);
return after;
// 审计在金额/状态快照之后写入,返回前重新读取,避免首个响应缺少最新修订。
return detail(before.customerId(), statementId);
}
/** 调整已出账明细并保留前后金额,响应与随后查询的修订历史一致。 */
private ReconciliationDetail adjust(long operatorId, long statementId, String key,
ReconciliationActionRequest request, Bill before) {
if (!"ISSUED".equals(before.status())) throw error("FIN_409", "仅已出账对账单允许调整");
@@ -174,7 +179,7 @@ public class ReconciliationStatementService {
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId);
complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId);
return after;
return detail(before.customerId(), statementId);
}
public ReconciliationDetail detail(long customerId, long statementId) {
@@ -195,7 +200,7 @@ public class ReconciliationStatementService {
List<ReconciliationRevision> revisions = jdbc.query(
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id",
(rs, row) -> revision(rs, row + 1), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), customerNames.resolver().apply(bill.customerId(), bill.customerName()),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
@@ -278,7 +283,7 @@ public class ReconciliationStatementService {
private CustomerProfile customer(long customerId) {
List<CustomerProfile> rows = jdbc.query("SELECT customer_name_snapshot,credit_days FROM fin_customer_settlement_profile WHERE tenant_id=? AND status='ACTIVE'",
(rs, row) -> new CustomerProfile(rs.getString("customer_name_snapshot"), rs.getInt("credit_days")), customerId);
return rows.isEmpty() ? new CustomerProfile("客户" + customerId, 30) : rows.get(0);
return new CustomerProfile(customerNames.required(customerId), rows.isEmpty() ? 30 : rows.get(0).creditDays());
}
private long activeSubject() {
@@ -488,18 +493,19 @@ public class ReconciliationStatementService {
}
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")),
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"),
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data"), "total"),
amountFromJson(rs.getString("after_data"), "totalReceivable"), rs.getString("reason"), rs.getLong("operator_id"),
rs.getString("operator_name_snapshot"),
rs.getTimestamp("created_at").toLocalDateTime());
}
private BigDecimal amountFromJson(String value) {
if (!StringUtils.hasText(value)) return BigDecimal.ZERO;
/** 变更前为 Bill,变更后为 ReconciliationDetail;缺失金额属于损坏快照,不能伪装为零。 */
private BigDecimal amountFromJson(String value, String field) {
try {
JsonNode node = objectMapper.readTree(value);
JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total");
return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue();
JsonNode node = objectMapper.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(value);
JsonNode amount = node.get(field);
if (amount == null || !amount.isNumber()) throw new IllegalArgumentException("缺失金额");
return amount.decimalValue();
} catch (Exception exception) {
throw error("FIN_001", "对账单修订快照损坏");
}
+2 -1
View File
@@ -437,6 +437,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
due_date DATE NOT NULL COMMENT '结算到期日期',
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
active_receivable_statement_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
@@ -444,7 +445,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no),
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id, active_receivable_statement_id),
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
KEY idx_fin_settlement_receivable_statistics (direction, currency_code, generated_date, status, tenant_id),
@@ -7,9 +7,16 @@ import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.finance.service.BusinessFeeSourceEventService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.repository.BusinessFeeSourceRepository;
import com.baiyue.finance.dto.BusinessFeeSourceVersion;
import com.baiyue.common.event.DeliveryAck;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -25,6 +32,7 @@ import static org.mockito.Mockito.never;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
@@ -37,7 +45,8 @@ class BusinessFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class);
private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers);
private final BusinessFeeSourceRepository sources = mock(BusinessFeeSourceRepository.class);
private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers, sources);
@Test
void importsOverseasFeeWithoutInventingInboundReference() {
@@ -116,19 +125,93 @@ class BusinessFeeSourceEventServiceTest {
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
/** 旧版本不能覆盖已有较新版本,客户相同时仍保留原乱序门禁。 */
@Test
void lateOlderVersionCannotSupersedeNewerSourceOrCost() {
when(suppliers.requireActive(71L, null)).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"),
eq("OVERSEAS_CONTAINER_FEE"), eq(701L)))
.thenReturn(List.of(Map.of("source_fee_version", 2)));
when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L))
.thenReturn(List.of(new BusinessFeeSourceVersion(2, 10001L)));
assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1)))
.hasMessageContaining("版本已过期");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
}
/** 已完成的同一事件不再查询会停用、改名或暂时不可用的供应商主档。 */
@ParameterizedTest
@ValueSource(strings = {"FIN_409", "FIN_503", "RENAMED"})
void completedReplayDoesNotDependOnCurrentSupplier(String change) {
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId())))
.thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L)));
if ("RENAMED".equals(change)) {
when(suppliers.requireActive(71L, null)).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "新供应商名称", null, "ACTIVE"));
} else {
when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(change, "供应商不可用"));
}
assertThat(service.consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE);
verify(suppliers, never()).requireActive(anyLong(), any());
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
/** 首次接收仍须失败关闭,不能为修复重放而绕过供应商校验。 */
@ParameterizedTest
@ValueSource(strings = {"FIN_409", "FIN_503"})
void newPayableStillRejectsUnavailableSupplier(String code) {
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(code, "供应商不可用"));
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("供应商不可用");
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
/** 两个摘要均正确也不能绕过金额算术、人民币汇率及精度约束。 */
@ParameterizedTest
@CsvSource({"USD,1.00,8.000000,120.00", "CNY,120.00,2.000000,240.00",
"USD,1.00,8.000000,8.001", "USD,1.001,8.000000,8.01", "USD,1.00,8.0000001,8.00"})
void rejectsInconsistentAmountOrPrecision(String currency, String original, String rate, String total) {
EventEnvelope event = convertedEvent(currency, original, rate, total);
assertThatThrownBy(() -> service.consume(event)).isInstanceOfSatisfying(FinanceEventException.class,
error -> assertThat(error.code()).isEqualTo("FIN-EVT-019"));
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
/** 分位舍入与数值相等的尾零均遵循生产端规则,不错误拒收合法费用。 */
@ParameterizedTest
@CsvSource({"USD,0.04,0.125000,0.01", "USD,16.85,7.122000,120.01", "CNY,120.0000,1.00000000,120.0000"})
void acceptsProducerRoundingAndEquivalentTrailingZeros(String currency, String original, String rate, String total) {
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(convertedEvent(currency, original, rate, total)).outcome())
.isEqualTo(DeliveryAck.Outcome.APPLIED);
}
/** 调整只允许原客户,错误归属不得替代旧费用或旧成本。 */
@Test
void crossCustomerRevisionCannotReplaceExistingSource() {
var event = payableEvent("ADJUSTED", 2);
when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L))
.thenReturn(List.of(new BusinessFeeSourceVersion(1, 10002L)));
assertThatThrownBy(() -> service.consume(event)).isInstanceOfSatisfying(FinanceEventException.class,
error -> assertThat(error.code()).isEqualTo("FIN-EVT-018"));
noFinancialWrites();
}
/** 创建金额测试快照时同时更新正式来源摘要与事件摘要。 */
private EventEnvelope convertedEvent(String currency, String original, String rate, String total) {
EventEnvelope base = eventWithoutInbound();
ObjectNode payload = base.payload().deepCopy();
payload.put("originalCurrency", currency).put("originalAmount", new BigDecimal(original))
.put("exchangeRate", new BigDecimal(rate)).put("totalAmount", new BigDecimal(total));
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
void replayReturnsOriginalSourceWithoutDuplicatingCost() {
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
@@ -0,0 +1,238 @@
package com.baiyue.finance;
import com.baiyue.common.event.DeliveryAck;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot;
import com.baiyue.finance.mapper.BusinessFeeSourceMapper;
import com.baiyue.finance.repository.BusinessFeeSourceRepository;
import com.baiyue.finance.service.BusinessFeeSourceEventService;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 隔离数据库执行真实 Mapper 和接收事务,验证失败不留下收件箱或费用半成品。 */
class BusinessFeeSourceReceptionPersistenceTest {
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class);
private JdbcTemplate jdbc;
private TransactionTemplate transaction;
private BusinessFeeSourceEventService service;
/** MyBatis 与既有接收写入共用 Spring 事务连接,不连接共享 TEST 数据库。 */
@BeforeEach
void setUp() throws Exception {
var data = new DriverManagerDataSource("jdbc:h2:mem:fee-reception-" + UUID.randomUUID()
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(data);
jdbc.execute("""
CREATE TABLE fin_fee_source(id BIGINT AUTO_INCREMENT PRIMARY KEY,
source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,
customer_id BIGINT,source_no VARCHAR(64),business_type VARCHAR(32),business_id BIGINT,
business_no VARCHAR(128),inbound_code VARCHAR(64),warehouse_id BIGINT,direction VARCHAR(16),
order_id BIGINT,inbound_id BIGINT,container_id BIGINT,container_booking_id BIGINT,container_no VARCHAR(64),
fee_item_code VARCHAR(64),fee_item_name VARCHAR(200),amount DECIMAL(20,6),currency_code VARCHAR(3),
settlement_mode VARCHAR(16),paid_at TIMESTAMP,payment_method VARCHAR(32),payment_reference_no VARCHAR(128),
payment_voucher_file_ids VARCHAR(2000),source_hash VARCHAR(71),source_snapshot VARCHAR(10000),
review_status VARCHAR(16),status VARCHAR(16),wallet_hold_id BIGINT,operation_version INT DEFAULT 0,
UNIQUE(source_system,source_type,source_fee_id,source_fee_version))
""");
jdbc.execute("""
CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64),
payload VARCHAR(15000),payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT,
processed_at TIMESTAMP,error_code VARCHAR(64),PRIMARY KEY(source_system,event_id))
""");
jdbc.execute("""
CREATE TABLE fin_cost(id BIGINT AUTO_INCREMENT PRIMARY KEY,tenant_id BIGINT,cost_no VARCHAR(200),
cost_type VARCHAR(32),cost_origin VARCHAR(32),source_system VARCHAR(32),source_type VARCHAR(32),
source_fee_id BIGINT,source_fee_version INT,source_hash VARCHAR(71),order_id BIGINT,inbound_id BIGINT,
container_id BIGINT,warehouse_id BIGINT,supplier_id BIGINT,supplier_name_snapshot VARCHAR(128),
fee_item_code VARCHAR(64),fee_item_name VARCHAR(200),currency_code VARCHAR(3),cost_amount DECIMAL(20,6),
voucher_file_ids VARCHAR(2000),paid_at TIMESTAMP,payment_method VARCHAR(32),payment_reference_no VARCHAR(128),
status VARCHAR(16),confirmed_at TIMESTAMP,created_by BIGINT,created_by_name VARCHAR(64),updated_by BIGINT,
voided_at TIMESTAMP,void_reason VARCHAR(200),version INT DEFAULT 0,
UNIQUE(source_system,source_type,source_fee_id,source_fee_version))
""");
jdbc.execute("CREATE TABLE fin_bill(id BIGINT,tenant_id BIGINT,status VARCHAR(16))");
jdbc.execute("CREATE TABLE fin_bill_line(bill_id BIGINT,tenant_id BIGINT,source_id BIGINT,source_version INT)");
jdbc.execute("CREATE TABLE fin_settlement(id BIGINT,tenant_id BIGINT,status VARCHAR(16))");
jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)");
var configuration = new Configuration();
configuration.addMapper(BusinessFeeSourceMapper.class);
var factory = new SqlSessionFactoryBean();
factory.setDataSource(data);
factory.setConfiguration(configuration);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(BusinessFeeSourceMapper.class);
service = new BusinessFeeSourceEventService(jdbc, json, suppliers, new BusinessFeeSourceRepository(mapper));
transaction = new TransactionTemplate(new DataSourceTransactionManager(data));
when(suppliers.requireActive(71L, null)).thenReturn(new SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
}
/** 释放每个用例独占的内存数据库。 */
@AfterEach
void tearDown() {
if (jdbc != null) jdbc.execute("SHUTDOWN");
}
/** 已提交后模拟丢失回执,供应商停用或故障都不产生第二笔费用和成本。 */
@ParameterizedTest
@ValueSource(strings = {"FIN_409", "FIN_503"})
void committedDirectPaidReplayKeepsExactlyOneSourceAndCost(String errorCode) {
var event = event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00");
assertThat(consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED);
reset(suppliers);
when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(errorCode, "供应商不可用"));
assertThat(consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE);
verifyNoInteractions(suppliers);
assertCounts(1, 1, 1);
assertThat(jdbc.queryForObject("SELECT status FROM fin_inbox_event", String.class)).isEqualTo("PROCESSED");
assertThat(jdbc.queryForObject("SELECT cost_amount FROM fin_cost", BigDecimal.class)).isEqualByComparingTo("12.00");
}
/** 不同客户的升版失败时回滚已插入 Inbox,原费用与原直付成本保持有效。 */
@Test
void foreignCustomerRevisionRollsBackAndSameCustomerCanStillAdjust() {
consume(event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00"));
var wrong = event(701, 2, 10002, "DIRECT_PAID", "CNY", "18.00", "1", "18.00");
assertThatThrownBy(() -> consume(wrong)).isInstanceOfSatisfying(FinanceEventException.class,
error -> assertThat(error.code()).isEqualTo("FIN-EVT-018"));
assertCounts(1, 1, 1);
assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source", String.class)).isEqualTo("AVAILABLE");
assertThat(jdbc.queryForObject("SELECT status FROM fin_cost", String.class)).isEqualTo("CONFIRMED");
assertThat(jdbc.queryForObject("SELECT customer_id FROM fin_fee_source", Long.class)).isEqualTo(10001L);
consume(event(701, 2, 10001, "DIRECT_PAID", "CNY", "18.00", "1", "18.00"));
assertCounts(2, 2, 2);
assertThat(jdbc.queryForList("SELECT status FROM fin_fee_source ORDER BY source_fee_version", String.class))
.containsExactly("SUPERSEDED", "AVAILABLE");
assertThat(jdbc.queryForList("SELECT status FROM fin_cost ORDER BY source_fee_version", String.class))
.containsExactly("VOIDED", "CONFIRMED");
}
/** 错误换算拒收后不残留收件记录,已存在版本也不会被替代。 */
@Test
void invalidConversionLeavesOriginalAmountsAndReceiptIntact() {
consume(event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00"));
assertThatThrownBy(() -> consume(event(701, 2, 10001, "ON_ACCOUNT", "USD", "1.00", "8", "120.00")))
.isInstanceOfSatisfying(FinanceEventException.class,
error -> assertThat(error.code()).isEqualTo("FIN-EVT-019"));
assertCounts(1, 1, 0);
assertThat(jdbc.queryForObject("SELECT amount FROM fin_fee_source", BigDecimal.class)).isEqualByComparingTo("12.00");
assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source", String.class)).isEqualTo("AVAILABLE");
}
/** 只锁定相同系统、来源类型和费用号,其他客户的独立费用不会阻塞正常升版。 */
@Test
void independentSourcesKeepSeparateCustomerIdentity() {
consume(event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00"));
consume(event(702, 1, 10002, "ON_ACCOUNT", "CNY", "9.00", "1", "9.00"));
consume(event(701, 2, 10001, "ON_ACCOUNT", "CNY", "18.00", "1", "18.00"));
assertCounts(3, 3, 0);
assertThat(jdbc.queryForObject("SELECT customer_id FROM fin_fee_source WHERE source_fee_id=702", Long.class))
.isEqualTo(10002L);
assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source WHERE source_fee_id=702", String.class))
.isEqualTo("AVAILABLE");
}
/** 提前恢复幂等结果不放宽事件冲突校验,异载荷仍须拒绝。 */
@Test
void changedPayloadCannotUseProcessedEventIdentity() {
var first = event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00");
consume(first);
var changed = event(701, 1, 10001, "ON_ACCOUNT", "CNY", "18.00", "1", "18.00");
var conflict = new EventEnvelope(first.eventId(), changed.eventType(), changed.eventVersion(), changed.occurredAt(),
changed.tenantId(), changed.aggregateType(), changed.aggregateId(), changed.aggregateVersion(), null,
changed.traceId(), changed.correlationId(), null, changed.payload(), changed.payloadHash());
reset(suppliers);
assertThatThrownBy(() -> consume(conflict)).isInstanceOfSatisfying(FinanceEventException.class,
error -> assertThat(error.code()).isEqualTo("FIN-EVT-002"));
verifyNoInteractions(suppliers);
assertCounts(1, 1, 0);
}
/** 即使失败发生在费用插入之后,收件箱、费用及成本也必须一起回滚。 */
@Test
void costWriteFailureRollsBackReceiptAndSource() {
jdbc.execute("ALTER TABLE fin_cost ADD CONSTRAINT test_cost_limit CHECK(cost_amount <= 10)");
assertThatThrownBy(() -> consume(event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00")))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
assertCounts(0, 0, 0);
}
/** 以正式服务事务边界执行,Mapper 与遗留 JDBC 写入必须共进退。 */
private DeliveryAck consume(EventEnvelope event) {
return transaction.execute(status -> service.consume(event));
}
/** 校验整笔接收事实数量,防止幂等通过但产生额外财务明细。 */
private void assertCounts(int inbox, int sources, int costs) {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Integer.class)).isEqualTo(inbox);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Integer.class)).isEqualTo(sources);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_cost", Integer.class)).isEqualTo(costs);
}
/** 构造经过两个正式摘要计算的费用快照,金额错误用例不会被摘要错误提前拦截。 */
private EventEnvelope event(long feeId, int version, long customer, String mode, String currency,
String original, String rate, String total) {
ObjectNode payload = json.createObjectNode();
payload.putNull("action");
payload.put("sourceSystem", "LASTLEG").put("sourceType", "OVERSEAS_WAREHOUSE_FEE")
.put("sourceFeeId", String.valueOf(feeId)).put("sourceVersion", version)
.put("customerId", String.valueOf(customer)).put("customerName", "验收客户")
.put("sourceNo", "ZY-RECEPTION").put("businessType", "WAREHOUSE_SERVICE")
.put("businessId", "801").put("businessNo", "ZY-RECEPTION");
for (String field : new String[]{"orderId", "inboundId", "inboundCode", "containerId", "containerBookingId", "containerNo"})
payload.putNull(field);
payload.put("warehouseId", "91").put("feeItemCode", "FEE_TYPE_003").put("feeItemName", "人工费")
.put("direction", "PAYABLE").put("supplierId", "71").put("supplierName", "供应商");
payload.putNull("supplierCategory"); payload.put("settlementMode", mode);
if ("DIRECT_PAID".equals(mode)) {
payload.set("paidAt", json.valueToTree(Instant.parse("2026-10-27T01:00:00Z")));
payload.put("paymentMethod", "BANK_TRANSFER").put("paymentReferenceNo", "TEST-RECEPTION");
} else {
payload.putNull("paidAt"); payload.putNull("paymentMethod"); payload.putNull("paymentReferenceNo");
}
payload.putArray("paymentVoucherFileIds");
payload.put("businessDate", "2026-10-27").put("currency", "CNY").put("totalAmount", new BigDecimal(total))
.put("originalCurrency", currency).put("originalAmount", new BigDecimal(original))
.put("exchangeRate", new BigDecimal(rate)).put("exchangeRateMonth", "2026-10");
for (String field : new String[]{"chargeableQuantity", "quantityUnit", "unitPrice", "periodStart", "periodEnd"})
payload.putNull(field);
payload.put("reviewStatus", "APPROVED").put("publicationStatus", "PUBLISHED").put("sourceStatus", "ACTIVE");
payload.putNull("sourceHash"); payload.putNull("walletSettlement");
// 使用正式 DTO 规范化日期等类型,和生产者的序列化输入保持一致。
payload = json.valueToTree(json.convertValue(payload, BusinessFeeSourceChangedV1.class));
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", version == 1 ? "PUBLISHED" : "ADJUSTED");
return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1,
Instant.parse("2026-10-27T01:00:00Z"), String.valueOf(customer), "BUSINESS_FEE", String.valueOf(feeId), version,
null, UUID.randomUUID().toString(), "TEST:FEE:" + feeId + ':' + version, null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
}
@@ -37,7 +37,7 @@ class CustomerAccountSummaryBehaviorTest {
when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L)))
.thenReturn(List.of());
var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L);
var result = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccount(9L);
assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER");
var amountSql = org.mockito.ArgumentCaptor.forClass(String.class);
@@ -60,7 +60,7 @@ class CustomerAccountSummaryBehaviorTest {
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
CustomerAccountSummary result = service.customerAccountSummary(9L);
@@ -99,7 +99,7 @@ class CustomerAccountSummaryBehaviorTest {
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccountSummary(77L);
assertThat(result.totalReceivable()).isZero();
assertThat(result.receivedAmount()).isZero();
@@ -116,7 +116,7 @@ class CustomerAccountSummaryBehaviorTest {
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L))
assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccountSummary(88L))
.isInstanceOf(BadSqlGrammarException.class);
}
@@ -0,0 +1,53 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.finance.client.IamCustomerClient;
import com.baiyue.finance.service.FinanceCustomerNames;
import com.baiyue.finance.service.FinanceEventException;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FinanceCustomerNamesTest {
private final IamCustomerClient iam = mock(IamCustomerClient.class);
private final FinanceCustomerNames names = new FinanceCustomerNames(iam, "internal-token");
@Test
void resolvesInternalLabelsAndMissingNamesOncePerCustomerWithoutReplacingValidSnapshots() {
respond(9, "当前公司名称");
var resolver = names.resolver();
assertThat(resolver.apply(9L, "客户9")).isEqualTo("当前公司名称");
assertThat(resolver.apply(9L, "9")).isEqualTo("当前公司名称");
assertThat(resolver.apply(9L, null)).isEqualTo("当前公司名称");
assertThat(resolver.apply(9L, "原单公司名称")).isEqualTo("原单公司名称");
verify(iam, times(1)).page("internal-token", 1, 1, null, null, 9L);
}
@Test
void missingCustomerDisplaysDashButCannotCreateNewDocument() {
when(iam.page("internal-token", 1, 1, null, null, 9L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 1, 0, 0)));
assertThat(names.resolver().apply(9L, "客户9")).isEqualTo("—");
assertThatThrownBy(() -> names.requiredSnapshot(9, "客户9")).isInstanceOf(FinanceEventException.class).hasMessageContaining("无法生成");
}
@Test
void rejectsMismatchedCustomerInsteadOfLeakingAnotherCompanyName() {
respond(10, "其他公司");
assertThatThrownBy(() -> names.required(9)).isInstanceOf(FinanceEventException.class).hasMessageContaining("范围异常");
}
@Test
void dependencyFailureCannotFallBackToInternalIdOrSilentlyCreateDocument() {
when(iam.page("internal-token", 1, 1, null, null, 9L)).thenThrow(new IllegalStateException("offline"));
assertThatThrownBy(() -> names.required(9)).isInstanceOf(FinanceEventException.class).hasMessageContaining("暂不可用");
assertThat(names.resolver().apply(9L, "已记录公司名称")).isEqualTo("已记录公司名称");
}
private void respond(long returnedId, String name) {
when(iam.page("internal-token", 1, 1, null, null, 9L)).thenReturn(ApiResponse.success(
new PageResult<>(List.of(new IamCustomerClient.Customer(returnedId, "TEST", name, "ACTIVE", null, null)), 1, 1, 1, 1)));
}
}
@@ -0,0 +1,22 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.finance.client.IamCustomerClient;
import com.baiyue.finance.service.FinanceCustomerNames;
import java.util.List;
import static org.mockito.Mockito.mock;
/** 财务服务单测使用真实名称解析器及隔离的 IAM 主档,不请求测试环境。 */
public final class FinanceCustomerNamesTestSupport {
private FinanceCustomerNamesTestSupport() {}
public static FinanceCustomerNames names() {
var iam = mock(IamCustomerClient.class, invocation -> {
if (!invocation.getMethod().getName().equals("page")) return org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation);
long id = invocation.getArgument(5);
return ApiResponse.success(new PageResult<>(List.of(new IamCustomerClient.Customer(id, "TEST", "测试客户公司", "ACTIVE", null, null)), 1, 1, 1, 1));
});
return new FinanceCustomerNames(iam, "internal-token");
}
}
@@ -0,0 +1,89 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceQueryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** 成本列表和详情须与直接已付事实一致,不能提示供应商费用仍待支付。 */
class FinanceDirectPaidCostReadTest {
private JdbcTemplate jdbc;
private FinanceQueryService service;
/** 使用隔离内存表验证正式读取路径,不访问共享测试环境。 */
@BeforeEach
void setUp() {
jdbc = new JdbcTemplate(new DriverManagerDataSource(
"jdbc:h2:mem:cost-read-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
jdbc.execute("CREATE TABLE fin_cost(id BIGINT PRIMARY KEY,tenant_id BIGINT,cost_no VARCHAR(128),"
+ "cost_type VARCHAR(24),cost_origin VARCHAR(24),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,"
+ "supplier_name_snapshot VARCHAR(128),fee_item_name VARCHAR(128),cost_amount DECIMAL(18,2),"
+ "currency_code VARCHAR(3),status VARCHAR(24),created_at TIMESTAMP,version INT)");
jdbc.execute("CREATE TABLE fin_cost_allocation(id BIGINT,tenant_id BIGINT,cost_id BIGINT,order_id BIGINT,"
+ "inbound_id BIGINT,volume_cbm DECIMAL(18,6),total_volume_cbm DECIMAL(18,6),allocation_rate DECIMAL(18,6),"
+ "allocated_amount DECIMAL(18,2),rounding_adjustment BOOLEAN)");
service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
}
/** 头程与海外直付均已付款,列表和详情不能再次显示未付余额。 */
@ParameterizedTest
@ValueSource(strings = {"BUSINESS_DIRECT_PAID", "FIRSTLEG_DIRECT_PAID"})
void confirmedDirectPaidCostIsFullyPaidInListAndDetail(String origin) {
insertCost(1, 11, "SOURCE_DIRECT_PAID", origin, "CONFIRMED");
var row = service.payableSettlements(11L).get(0);
assertThat(row.payableAmount()).isEqualByComparingTo("0.03");
assertThat(row.paidAmount()).isEqualByComparingTo("0.03");
assertThat(row.outstandingAmount()).isZero();
assertThat(service.cost(11, 1).cost()).isEqualTo(row);
}
/** 已作废的直付历史保留原金额,但不再作为有效已付或待付款展示。 */
@Test
void voidedDirectPaidCostHasNoActivePaymentOrOutstandingAmount() {
insertCost(1, 11, "SOURCE_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "VOIDED");
var row = service.payableSettlements(11L).get(0);
assertThat(row.payableAmount()).isEqualByComparingTo("0.03");
assertThat(row.paidAmount()).isZero();
assertThat(row.outstandingAmount()).isZero();
assertThat(row.status()).isEqualTo("VOIDED");
}
/** 人工补充成本没有直接已付事实,不能被自动视为付款完成。 */
@ParameterizedTest
@ValueSource(strings = {"SINGLE_ORDER", "CONTAINER_SHARED"})
void manualCostKeepsItsExistingUnpaidAmounts(String type) {
insertCost(1, 11, type, "MANUAL", "CONFIRMED");
var row = service.payableSettlements(11L).get(0);
assertThat(row.paidAmount()).isZero();
assertThat(row.outstandingAmount()).isEqualByComparingTo("0.03");
}
/** 金额回显修复不能让其他客户读到成本列表或详情。 */
@Test
void costReadsKeepCustomerIsolation() {
insertCost(1, 11, "SOURCE_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "CONFIRMED");
assertThat(service.payableSettlements(12L)).isEmpty();
assertThatThrownBy(() -> service.cost(12, 1)).hasMessageContaining("成本不存在或不属于客户");
}
/** 仅构造测试所需的成本事实,金额对应本轮发现问题的小额样本。 */
private void insertCost(long id, long customer, String type, String origin, String status) {
jdbc.update("INSERT INTO fin_cost(id,tenant_id,cost_no,cost_type,cost_origin,order_id,container_id,"
+ "supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version) "
+ "VALUES(?,?,?,?,?,101,201,'验收供应商','人工费',0.03,'CNY',?,CURRENT_TIMESTAMP,0)",
id, customer, "COST-READ-TEST", type, origin, status);
}
}
@@ -18,7 +18,7 @@ class FinanceFeePoolDirectionBehaviorTest {
@Test
void receivablePoolNeverContainsPayableSource() {
DirectionJdbc jdbc = new DirectionJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
List<FeePoolItem> result = service.feePool(7L, "RECEIVABLE");
@@ -30,7 +30,7 @@ class FinanceFeePoolDirectionBehaviorTest {
@Test
void payablePoolNeverContainsReceivableSource() {
DirectionJdbc jdbc = new DirectionJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
List<FeePoolItem> result = service.feePool(7L, "PAYABLE");
@@ -42,7 +42,7 @@ class FinanceFeePoolDirectionBehaviorTest {
@Test
void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() {
DirectionJdbc jdbc = new DirectionJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
service.feePool(7L, "PAYABLE", 81L);
@@ -15,6 +15,7 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -30,8 +31,11 @@ public class FinanceFeeSourceReadMapperTest {
try (var connection = data.getConnection()) {
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'");
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)");
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP,settlement_mode VARCHAR(32),paid_at TIMESTAMP,operation_version INT DEFAULT 0,review_reason VARCHAR(128),source_hash VARCHAR(64))");
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT,tenant_id BIGINT,bill_id BIGINT)");
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT,status VARCHAR(24))");
jdbc.execute("CREATE TABLE fin_settlement(id BIGINT PRIMARY KEY,tenant_id BIGINT,direction VARCHAR(16),status VARCHAR(24),payment_status VARCHAR(24))");
jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)");
jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)");
insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00");
@@ -103,10 +107,44 @@ public class FinanceFeeSourceReadMapperTest {
assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)");
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
assertThat(repository.count(filters)).isEqualTo(1);
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
if ("RECEIVABLE".equals(direction)) {
jdbc.update("INSERT INTO fin_bill VALUES(91,9,'ISSUED')");
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2,9,91)");
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
session.clearCache();
assertThat(repository.count(filters)).isEqualTo(1);
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
assertThat(repository.detail(2).sourceStatus()).isEqualTo("BILLED");
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=91");
session.clearCache();
assertThat(repository.detail(2).statementGenerated()).isFalse();
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
} else {
jdbc.update("INSERT INTO fin_settlement VALUES(91,9,'PAYABLE','ISSUED','UNPAID')");
jdbc.update("INSERT INTO fin_settlement_line VALUES(91,9,2)");
for (String payment : List.of("UNPAID", "PARTIALLY_PAID", "PAID")) {
jdbc.update("UPDATE fin_settlement SET payment_status=? WHERE id=91", payment);
String expected = "UNPAID".equals(payment) ? "PAYABLE_BILLED" : payment;
filters.put("status", expected);
assertThat(repository.count(filters)).isEqualTo(1);
assertThat(repository.list(filters)).singleElement().satisfies(row -> {
assertThat(row.feeSourceId()).isEqualTo(2);
assertThat(row.sourceStatus()).isEqualTo(expected);
assertThat(row.statementGenerated()).isFalse();
assertThat(row.amount()).isEqualByComparingTo("120");
});
assertThat(repository.detail(2).sourceStatus()).isEqualTo(expected);
}
jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=91");
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
jdbc.update("UPDATE fin_settlement SET status='PAID',tenant_id=10 WHERE id=91");
jdbc.update("UPDATE fin_settlement_line SET tenant_id=10 WHERE settlement_id=91");
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
jdbc.update("UPDATE fin_fee_source SET settlement_mode='DIRECT_PAID',paid_at='2026-10-21 10:00:00' WHERE id=2");
assertThat(repository.detail(2).sourceStatus()).isEqualTo("PAID");
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=2");
assertThat(repository.detail(2).sourceStatus()).isEqualTo("VOIDED");
}
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
@@ -189,8 +189,8 @@ class FinancePhase1OperationBehaviorTest {
/** 保留费用审核状态读取。 */
@Test
void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 2));
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
}
@@ -199,7 +199,7 @@ class FinancePhase1OperationBehaviorTest {
@Test
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
FinancePhase1OperationService service = new FinancePhase1OperationService(
feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
mock(JdbcTemplate.class), objectMapper(), null, "", feeSources("APPROVED", true, 2));
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
@@ -212,8 +212,8 @@ class FinancePhase1OperationBehaviorTest {
/** 业务来源费用必须回到所属系统调整。 */
@Test
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 1));
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1);
@@ -228,7 +228,7 @@ class FinancePhase1OperationBehaviorTest {
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
@Test
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of());
FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
@@ -236,7 +236,7 @@ class FinancePhase1OperationBehaviorTest {
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
FinancePhase1OperationService service = new FinancePhase1OperationService(
jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
jdbc, objectMapper(), firstleg, "internal-token", feeSources("APPROVED", false, 2));
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
@@ -262,13 +262,9 @@ class FinancePhase1OperationBehaviorTest {
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AtomicInteger reads = new AtomicInteger();
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
String review = reads.getAndIncrement() == 0 ? "PENDING" : "APPROVED";
int version = "PENDING".equals(review) ? 0 : 1;
return List.of(mapper.mapRow(resultSet(review, false, version), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
var sources = mock(FinanceFeeSourceReadRepository.class);
when(sources.detail(88L)).thenAnswer(invocation -> reads.getAndIncrement() == 0
? feeDetail("PENDING", false, 0) : feeDetail("APPROVED", false, 1));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
@@ -280,7 +276,7 @@ class FinancePhase1OperationBehaviorTest {
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", sources);
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
@@ -332,45 +328,30 @@ class FinancePhase1OperationBehaviorTest {
.when(service).getFinanceFeeSource(88L);
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
for (String occupied : List.of("PAYABLE_BILLED", "PARTIALLY_PAID", "PAID")) {
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("sourceStatus", occupied), FeeSourceDetail.class))
.when(service).getFinanceFeeSource(88L);
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "occupied-" + occupied, request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("费用已被占用、结清或作废");
}
}
/** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
return jdbc;
/** 服务行为测试由 Repository 返回明确快照;正式 SQL 与 DTO 映射由 Mapper 数据库测试覆盖。 */
private static FinanceFeeSourceReadRepository feeSources(String review, boolean generated, int version) throws Exception {
var repository = mock(FinanceFeeSourceReadRepository.class);
when(repository.detail(88L)).thenReturn(feeDetail(review, generated, version));
return repository;
}
private static ResultSet resultSet(String reviewStatus, boolean included, int version) throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(700L);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(90L);
when(rs.getLong("inbound_id")).thenReturn(100L);
when(rs.getLong("container_id")).thenReturn(200L);
when(rs.wasNull()).thenReturn(false, false, true, false, false, true);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_no")).thenReturn("IN-100");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("头程运费");
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("status")).thenReturn("AVAILABLE");
when(rs.getString("source_hash")).thenReturn("a".repeat(64));
when(rs.getString("review_status")).thenReturn(reviewStatus);
when(rs.getString("review_reason")).thenReturn(null);
when(rs.getInt("operation_version")).thenReturn(version);
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"inboundCode\":\"NYEXX2608180003\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
return rs;
private static FeeSourceDetail feeDetail(String review, boolean generated, int version) throws Exception {
var node = objectMapper().createObjectNode().put("feeSourceId",88).put("sourceSystem","FIRSTLEG")
.put("sourceType","INBOUND_FEE").put("sourceFeeId",700).put("sourceVersion",3).put("customerId",9)
.put("direction","RECEIVABLE").put("orderId",90).put("inboundId",100).put("containerId",200)
.put("inboundCode","NYEXX2608180003").put("sourceBusinessId","IN-100").put("feeItemName","头程运费")
.put("amount",100).put("currency","CNY").put("operationVersion",version).put("reviewStatus",review)
.put("sourceStatus",generated ? "BILLED" : "AVAILABLE").put("statementGenerated",generated)
.put("sourceHash","a".repeat(64)).put("businessDate","2026-08-11");
return objectMapper().treeToValue(node,FeeSourceDetail.class);
}
private static ObjectMapper objectMapper() {
@@ -16,7 +16,7 @@ class FinancePlatformOptionalCustomerQueryTest {
@Test
void allCustomerQueriesOmitCustomerPredicateAndArguments() {
RecordingJdbc jdbc = new RecordingJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
service.feePool(null, null);
service.statements(null, false);
@@ -33,7 +33,7 @@ class FinancePlatformOptionalCustomerQueryTest {
@Test
void customerFilterAddsPredicateAndRealCustomerArgument() {
RecordingJdbc jdbc = new RecordingJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
service.feePool(31L, null);
service.statements(31L, false);
@@ -49,7 +49,7 @@ class FinancePlatformOptionalCustomerQueryTest {
@Test
void optionalCustomerNeverTreatsZeroAsAllCustomers() {
FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper());
FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效");
assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效");
@@ -20,6 +20,8 @@ import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -100,6 +102,7 @@ class FinanceSettlementBehaviorTest {
assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L);
assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L);
assertThat(scoped).singleElement().extracting(SettlementSummary::customerName).isEqualTo("测试客户公司");
ArgumentCaptor<String> allSql = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
@@ -221,6 +224,143 @@ class FinanceSettlementBehaviorTest {
verify(staleJdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
}
/** 作废应付的费用已被另一张有效单占用时,重开必须拒绝,不能形成双重应付。 */
@Test
void reopeningCannotReclaimASourceUsedByAnotherSettlement() {
Map<String, Object> row = settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
new BigDecimal("100.00"), 3);
JdbcTemplate jdbc = creationJdbc(row);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(row));
var source = payableSource(10L, 20L);
source.put("fee_source_id", 31L);
source.put("line_amount", new BigDecimal("100.00"));
source.put("line_currency", "CNY");
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L)))
.thenReturn(List.of(source));
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(88L)))
.thenReturn(List.of(Map.of("id", 99L)));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.actionSettlement(88L, 7L, "reopen-used",
new SettlementActionRequest("REOPEN", "验证来源占用", 3)), "FIN_409");
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
}
/** 未变化且无其他占用的应付原单可以恢复,继续保留原金额与修订审计。 */
@Test
void unchangedPayableCanReopen() {
JdbcTemplate jdbc = reopenJdbc("PAYABLE");
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertThat(service.actionSettlement(88L, 7L, "reopen-ok",
new SettlementActionRequest("REOPEN", "恢复有效原单", 3)).summary().status()).isEqualTo("ISSUED");
verify(jdbc).update(startsWith("UPDATE fin_settlement SET status="), eq("ISSUED"), eq(7L), eq(88L), eq(10L), eq(3));
verify(jdbc).update(startsWith("INSERT INTO fin_audit_log"), eq(10L), eq("FIN_SETTLEMENT"), eq(88L),
eq("REOPEN"), eq("恢复有效原单"), any(), any(), eq(7L), any(), any(), any());
}
/** 原应付的失效、金额和归属变化均不能借重开恢复旧快照。 */
@ParameterizedTest
@ValueSource(strings = {"VOIDED", "SUPERSEDED", "PENDING", "DIRECT_PAID", "CROSS_CUSTOMER", "MISSING", "AMOUNT_CHANGED", "SUPPLIER_CHANGED", "EMPTY"})
void reopeningRejectsChangedPayableSources(String condition) {
JdbcTemplate jdbc = reopenJdbc("PAYABLE");
Map<String, Object> source = reopenSource("PAYABLE");
switch (condition) {
case "VOIDED", "SUPERSEDED" -> source.put("status", condition);
case "PENDING" -> source.put("review_status", "PENDING");
case "DIRECT_PAID" -> source.put("settlement_mode", "DIRECT_PAID");
case "CROSS_CUSTOMER" -> source.put("customer_id", 11L);
case "MISSING" -> source.remove("id");
case "AMOUNT_CHANGED" -> source.put("amount", new BigDecimal("101.00"));
case "SUPPLIER_CHANGED" -> source.put("source_snapshot", "{\"supplierId\":21}");
default -> { }
}
when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L)))
.thenReturn("EMPTY".equals(condition) ? List.of() : List.of(source));
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L,
"reopen-" + condition, new SettlementActionRequest("REOPEN", "核对原始费用", 3)), "FIN_409");
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
}
/** 应收原账单与明细未变化且仍已出账时,允许恢复原单。 */
@Test
void unchangedReceivableCanReopen() {
JdbcTemplate jdbc = reopenJdbc("RECEIVABLE");
assertThat(service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L,
"reopen-ar", new SettlementActionRequest("REOPEN", "恢复原应收", 3)).summary().status()).isEqualTo("ISSUED");
}
/** 其他应收占用、对账单作废或调整、已有收款均拒绝恢复旧应收。 */
@ParameterizedTest
@ValueSource(strings = {"OCCUPIED", "VOIDED_BILL", "ADJUSTED_TOTAL", "ADJUSTED_LINES", "MISSING_BILL", "RECEIPT", "WRONG_SOURCE", "EMPTY"})
void reopeningRejectsChangedReceivableBasis(String condition) {
JdbcTemplate jdbc = reopenJdbc("RECEIVABLE");
Map<String, Object> bill = reopenBill();
if ("VOIDED_BILL".equals(condition)) bill.put("status", "VOIDED");
if ("ADJUSTED_TOTAL".equals(condition)) bill.put("total_amount", new BigDecimal("101.00"));
if ("RECEIPT".equals(condition)) bill.put("received_amount", new BigDecimal("0.01"));
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,status,currency_code"), eq(10L), eq(501L)))
.thenReturn("MISSING_BILL".equals(condition) ? List.of() : List.of(bill));
if ("OCCUPIED".equals(condition))
when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(88L)))
.thenReturn(List.of(Map.of("id", 99L)));
if (List.of("ADJUSTED_LINES", "WRONG_SOURCE", "EMPTY").contains(condition))
when(jdbc.queryForList(startsWith("SELECT source_id fee_source_id"), eq(10L), eq(501L)))
.thenReturn("EMPTY".equals(condition) ? List.of() : List.of(Map.of("fee_source_id", "WRONG_SOURCE".equals(condition) ? 32L : 31L,
"line_amount", new BigDecimal("ADJUSTED_LINES".equals(condition) ? "99.00" : "100.00"), "line_currency", "CNY")));
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L,
"reopen-ar-" + condition, new SettlementActionRequest("REOPEN", "核对原对账单", 3)), "FIN_409");
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
}
/** 重放已成功的重开请求恢复当前单据,不再次修改版本或占用来源。 */
@Test
void successfulReopenReplayDoesNotReacquireSources() {
JdbcTemplate jdbc = reopenJdbc("PAYABLE");
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
var request = new SettlementActionRequest("REOPEN", "恢复原单", 3);
String path = "/api/platform/finance/v1/settlements/88/actions";
String hash = ReflectionTestUtils.invokeMethod(service, "hash", Map.of("method", "POST", "path", path, "customerId", 10L, "body", request));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_STATE_ACTION"), eq("replay-open")))
.thenReturn(List.of(Map.of("request_hash", hash, "status", "SUCCEEDED", "result_resource_id", 88L)));
assertThat(service.actionSettlement(88L, 7L, "replay-open", request).summary().status()).isEqualTo("ISSUED");
verify(jdbc, never()).queryForList(startsWith("SELECT sl.fee_source_id"), (Object[]) any());
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
}
/** 为重开场景准备完整且默认有效的原单、来源与对账单。 */
private static JdbcTemplate reopenJdbc(String direction) {
var settlement = settlementRow(direction, "VOIDED", BigDecimal.ZERO, new BigDecimal("100.00"), 3);
settlement.put("reconciliation_statement_id", 501L);
JdbcTemplate jdbc = creationJdbc(settlementRow(direction, "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 4));
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(settlement));
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L)))
.thenReturn(List.of(reopenSource(direction)));
if ("RECEIVABLE".equals(direction)) {
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT id,tenant_id,status,currency_code"), eq(10L), eq(501L)))
.thenReturn(List.of(reopenBill()));
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT source_id fee_source_id"), eq(10L), eq(501L)))
.thenReturn(List.of(Map.of("fee_source_id", 31L, "line_amount", new BigDecimal("100.00"), "line_currency", "CNY")));
}
return jdbc;
}
/** 提供带原结算金额的真实形状费用记录。 */
private static Map<String, Object> reopenSource(String direction) {
var row = payableSource(10L, 20L);
row.put("direction", direction);
row.put("status", "RECEIVABLE".equals(direction) ? "BILLED" : "AVAILABLE");
row.put("fee_source_id", 31L); row.put("line_amount", new BigDecimal("100.00")); row.put("line_currency", "CNY");
return row;
}
/** 提供已出账且未发生收款的对账单。 */
private static Map<String, Object> reopenBill() {
return new java.util.HashMap<>(Map.of("id", 501L, "tenant_id", 10L, "status", "ISSUED", "currency_code", "CNY",
"total_amount", new BigDecimal("100.00"), "received_amount", BigDecimal.ZERO));
}
@Test
void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
@@ -275,7 +415,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(0);
when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
@@ -357,7 +497,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-duplicate"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of(Map.of("id", 99L)));
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createReceivableSettlement(7L, "ar-duplicate",
new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409");
@@ -370,7 +510,7 @@ class FinanceSettlementBehaviorTest {
new BigDecimal("100.00"), 0));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0);
when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L))).thenReturn(List.of());
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), supplierValidator);
@@ -421,8 +561,8 @@ class FinanceSettlementBehaviorTest {
if ("ineligible".equals(condition)) source.put("review_status", "PENDING");
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-" + condition))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source));
org.mockito.Mockito.lenient().when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L)))
.thenReturn("occupied".equals(condition) ? 1 : 0);
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L)))
.thenReturn("occupied".equals(condition) ? List.of(Map.of("id", 99L)) : List.of());
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createPayableSettlement(7L, "ap-" + condition,
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_409");
@@ -578,7 +718,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of(Map.of("id", 99L)));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createReceivableSettlement(7L, "ar-key", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409");
@@ -597,7 +737,7 @@ class FinanceSettlementBehaviorTest {
JdbcTemplate occupiedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(occupiedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("occupied-key"))).thenReturn(List.of());
when(occupiedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(occupiedJdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(1);
when(occupiedJdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L))).thenReturn(List.of(Map.of("id", 99L)));
FinanceSettlementService occupiedService = service(occupiedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> occupiedService.createPayableSettlement(7L, "occupied-key", request), "FIN_409");
}
@@ -632,8 +772,15 @@ class FinanceSettlementBehaviorTest {
String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
return jdbc.queryForList(sql, invocation.getArguments());
});
var stateMapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementStateMapper.class, invocation -> {
var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
if (select != null) return jdbc.queryForList(String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"), invocation.getArguments());
var update = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Update.class);
if (update != null) return jdbc.update(String.join(" ", update.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"), invocation.getArguments());
return RETURNS_DEFAULTS.answer(invocation);
});
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper), FinanceCustomerNamesTestSupport.names(), new com.baiyue.finance.repository.FinanceSettlementStateRepository(stateMapper));
}
private static FinanceSupplierValidator supplierValidator() {
@@ -1,12 +1,20 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
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.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 FinanceSettlementSchemaContractTest {
@@ -31,6 +39,51 @@ class FinanceSettlementSchemaContractTest {
assertThat(unified).doesNotContain("ALTER TABLE fin_settlement", "assert_finance");
}
/** 执行两份真实基线的结算表定义,作废历史不占位,有效应收仍由数据库兜底唯一。 */
@ParameterizedTest
@ValueSource(booleans = {false, true})
void voidedReceivableCanBeReplacedWithoutAllowingDuplicateActiveSettlement(boolean unified) throws Exception {
Path source = unified ? resolveUnifiedSql() : Path.of("src/main/resources/db/finance.sql");
String sql = Files.readString(source, StandardCharsets.UTF_8);
var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_settlement \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql);
assertThat(table.find()).isTrue();
// H2 仅去除 MySQL 的存储、注释和普通索引语法,保留生成列表达式及所有唯一约束。
String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");")
.replaceAll("(?i) COMMENT '(?:[^']|'')*'", "")
.replaceAll("(?i)\\bUNSIGNED\\b|\\bVIRTUAL\\b|\\bSTORED\\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);
insertSettlement(jdbc, 1, 10, "AR-OLD", "RECEIVABLE", 501L, "VOIDED");
insertSettlement(jdbc, 2, 10, "AR-REPLACEMENT", "RECEIVABLE", 501L, "ISSUED");
insertSettlement(jdbc, 3, 10, "AR-HISTORY", "RECEIVABLE", 501L, "VOIDED");
for (String status : new String[]{"ISSUED", "PARTIALLY_PAID", "PAID"}) {
jdbc.update("UPDATE fin_settlement SET status=? WHERE id=2", status);
assertThatThrownBy(() -> insertSettlement(jdbc, 4, 10, "AR-DUP", "RECEIVABLE", 501L, "ISSUED"))
.isInstanceOf(DuplicateKeyException.class);
assertThatThrownBy(() -> jdbc.update("UPDATE fin_settlement SET status='ISSUED' WHERE id=1"))
.isInstanceOf(DuplicateKeyException.class);
}
jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=2");
assertThat(jdbc.update("UPDATE fin_settlement SET status='ISSUED' WHERE id=1")).isEqualTo(1);
insertSettlement(jdbc, 5, 11, "AR-OTHER-CUSTOMER", "RECEIVABLE", 501L, "ISSUED");
insertSettlement(jdbc, 6, 10, "AP-ONE", "PAYABLE", null, "ISSUED");
insertSettlement(jdbc, 7, 10, "AP-TWO", "PAYABLE", null, "ISSUED");
assertThatThrownBy(() -> insertSettlement(jdbc, 8, 10, "AR-OLD", "RECEIVABLE", 502L, "VOIDED"))
.isInstanceOf(DuplicateKeyException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_settlement", Integer.class)).isEqualTo(6);
}
/** 构造必填字段完整的结算行,仅改变本测试关注的客户、来源与状态。 */
private static void insertSettlement(JdbcTemplate jdbc, long id, long customerId, String no,
String direction, Long billId, String status) {
jdbc.update("INSERT INTO fin_settlement(id,tenant_id,settlement_no,direction,customer_name_snapshot," +
"reconciliation_statement_id,total_amount,paid_amount,outstanding_amount,generated_date,due_date,status,created_by,updated_by) " +
"VALUES (?,?,?,?,'合成测试客户',?,0.03,0,0.03,CURRENT_DATE,CURRENT_DATE,?,7,7)",
id, customerId, no, direction, billId, status);
}
private static Path resolveUnifiedSql() {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
@@ -0,0 +1,123 @@
package com.baiyue.finance;
import com.baiyue.finance.mapper.FinanceSettlementStateMapper;
import com.baiyue.finance.repository.FinanceSettlementStateRepository;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FinanceSettlementStateMapperTest {
/** 真正执行隔离查询,既保留损坏明细供业务拒绝,也不把其他客户占用算入当前客户。 */
@Test
void originalRowsAndOccupancyStayCustomerScoped() {
var fixture = fixture();
fixture.jdbc.update("INSERT INTO fin_fee_source VALUES (32,11,'PAYABLE','CNY',100,'APPROVED','AVAILABLE','ON_ACCOUNT','{}')");
fixture.jdbc.update("INSERT INTO fin_settlement_line VALUES (3,10,88,32,100,'CNY'),(4,11,88,31,100,'CNY')");
try (var session = fixture.factory.openSession()) {
var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class));
assertThat(repository.lockSources(10, 88)).hasSize(2);
assertThat(repository.lockSources(10, 88).get(1).get("customer_id")).isEqualTo(11L);
assertThat(repository.sourceOccupied(10, 31, 88)).isFalse();
assertThat(repository.sourceOccupied(11, 31, 88)).isFalse();
assertThat(repository.lockBill(10, 501)).hasSize(1);
assertThat(repository.lockBill(11, 501)).isEmpty();
assertThat(repository.lockBillLines(10, 501)).hasSize(1);
assertThat(repository.lockBillLines(11, 501)).isEmpty();
}
}
/** 同一会话重复校验必须读取最新状态,状态写入继续校验客户与版本。 */
@Test
void stateUpdatesCannotReuseCachedOccupancyOrBypassVersion() throws Exception {
var fixture = fixture();
try (var session = fixture.factory.openSession()) {
var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class));
assertThat(repository.sourceOccupied(10, 31, 99)).isFalse();
assertThat(repository.changeStatus("ISSUED", 7, 88, 11, 3)).isZero();
assertThat(repository.changeStatus("ISSUED", 7, 88, 10, 2)).isZero();
assertThat(repository.changeStatus("ISSUED", 7, 88, 10, 3)).isEqualTo(1);
assertThat(repository.sourceOccupied(10, 31, 99)).isTrue();
assertThat(repository.lockSettlement(88).get(0).get("version")).isEqualTo(4);
assertThat(repository.lockSources(10, 88).get(0).get("status")).isEqualTo("AVAILABLE");
try (var statement = session.getConnection().createStatement()) {
statement.executeUpdate("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE id=31");
}
assertThat(repository.lockSources(10, 88).get(0).get("status")).isEqualTo("SUPERSEDED");
}
}
/** 同账单唯一应收采用当前读取,排除自身、作废单及其他客户。 */
@Test
void receivableOccupancyReflectsCurrentActiveSettlement() {
var fixture = fixture();
fixture.jdbc.update("UPDATE fin_settlement SET direction='RECEIVABLE',reconciliation_statement_id=501,status='ISSUED' WHERE id=88");
try (var session = fixture.factory.openSession()) {
var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class));
assertThat(repository.billOccupied(10, 501, 99)).isTrue();
assertThat(repository.billOccupied(10, 501, 88)).isFalse();
assertThat(repository.billOccupied(11, 501, 99)).isFalse();
repository.changeStatus("VOIDED", 7, 88, 10, 3);
assertThat(repository.billOccupied(10, 501, 99)).isFalse();
}
}
/** 两个事务争用同一来源时,后到事务须等待并读取先到事务提交后的占用。 */
@Test
void sourceLockSerializesCompetingReopens() throws Exception {
var fixture = fixture();
var executor = Executors.newSingleThreadExecutor();
try (var first = fixture.factory.openSession()) {
var repository = new FinanceSettlementStateRepository(first.getMapper(FinanceSettlementStateMapper.class));
repository.lockSources(10, 88);
var started = new CountDownLatch(1);
var second = executor.submit(() -> {
try (var session = fixture.factory.openSession()) {
var other = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class));
started.countDown();
other.lockSources(10, 99);
return other.sourceOccupied(10, 31, 99);
}
});
assertThat(started.await(2, TimeUnit.SECONDS)).isTrue();
assertThatThrownBy(() -> second.get(150, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
repository.changeStatus("ISSUED", 7, 88, 10, 3);
first.commit();
assertThat(second.get(3, TimeUnit.SECONDS)).isTrue();
} finally {
executor.shutdownNow();
}
}
/** 使用独立内存库执行真实 MyBatis SQL,不启动本地业务服务。 */
private static Fixture fixture() {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:reopen-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000", "sa", "");
var jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE TABLE fin_settlement(id BIGINT PRIMARY KEY,tenant_id BIGINT,total_amount DECIMAL(18,2),paid_amount DECIMAL(18,2),outstanding_amount DECIMAL(18,2),payment_status VARCHAR(32),status VARCHAR(32),version INT,direction VARCHAR(32),currency_code VARCHAR(3),supplier_id BIGINT,reconciliation_statement_id BIGINT,updated_by BIGINT)");
jdbc.execute("CREATE TABLE fin_settlement_line(id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,fee_source_id BIGINT,amount DECIMAL(18,2),currency_code VARCHAR(3))");
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,customer_id BIGINT,direction VARCHAR(32),currency_code VARCHAR(3),amount DECIMAL(18,2),review_status VARCHAR(32),status VARCHAR(32),settlement_mode VARCHAR(32),source_snapshot VARCHAR(500))");
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT,status VARCHAR(32),currency_code VARCHAR(3),total_amount DECIMAL(18,2),received_amount DECIMAL(18,2))");
jdbc.execute("CREATE TABLE fin_bill_line(id BIGINT PRIMARY KEY,tenant_id BIGINT,bill_id BIGINT,source_id BIGINT,amount DECIMAL(18,2),currency_code VARCHAR(3))");
jdbc.update("INSERT INTO fin_settlement VALUES (88,10,100,0,100,'UNPAID','VOIDED',3,'PAYABLE','CNY',20,NULL,7),(99,10,100,0,100,'UNPAID','VOIDED',3,'PAYABLE','CNY',20,NULL,7)");
jdbc.update("INSERT INTO fin_settlement_line VALUES (1,10,88,31,100,'CNY'),(2,10,99,31,100,'CNY')");
jdbc.update("INSERT INTO fin_fee_source VALUES (31,10,'PAYABLE','CNY',100,'APPROVED','AVAILABLE','ON_ACCOUNT','{}')");
jdbc.update("INSERT INTO fin_bill VALUES (501,10,'ISSUED','CNY',100,0)");
jdbc.update("INSERT INTO fin_bill_line VALUES (1,10,501,31,100,'CNY')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource));
configuration.addMapper(FinanceSettlementStateMapper.class);
return new Fixture(jdbc, new SqlSessionFactoryBuilder().build(configuration));
}
private record Fixture(JdbcTemplate jdbc, SqlSessionFactory factory) { }
}
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(23);
assertThat(columnCount).isEqualTo(352);
assertThat(columnCount).isEqualTo(353);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -28,7 +28,7 @@ class ReceivableStatisticsServiceTest {
row(10001, "客户A", "300.00", "100.00", "200.00"),
row(10002, "客户B", "80.00", "80.00", "0.00"));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows);
ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc);
ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names());
var result = service.query(YearMonth.of(2026, 7), null, 1, 1);
@@ -51,12 +51,35 @@ class ReceivableStatisticsServiceTest {
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99")));
assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc)
assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names())
.query(YearMonth.of(2026, 7), null, 1, 20))
.isInstanceOf(FinanceEventException.class)
.hasMessageContaining("勾稽异常");
}
@Test
@SuppressWarnings("unchecked")
void queryAndExportResolveTheSameCustomerNameWithoutChangingAmounts() throws Exception {
var jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
RowMapper<ReceivableStatisticsRow> mapper = invocation.getArgument(1);
var rs = mock(java.sql.ResultSet.class);
when(rs.getLong("tenant_id")).thenReturn(9L);
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal("300.00"));
when(rs.getBigDecimal("total_received")).thenReturn(new BigDecimal("100.00"));
when(rs.getBigDecimal("total_outstanding")).thenReturn(new BigDecimal("200.00"));
return List.of(mapper.mapRow(rs, 0));
});
var service = new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names());
var query = service.query(YearMonth.of(2026, 10), 9L, 1, 20);
assertThat(query.records()).singleElement().satisfies(row -> {
assertThat(row.customerName()).isEqualTo("测试客户公司");
assertThat(row.totalReceivable()).isEqualByComparingTo("300.00");
});
assertThat(service.exportRows(YearMonth.of(2026, 10), 9L)).isEqualTo(query.records());
}
private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) {
return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total),
new BigDecimal(received), new BigDecimal(outstanding));
@@ -11,6 +11,8 @@ import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
@@ -83,6 +85,8 @@ class ReconciliationStatementBehaviorTest {
@Test
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
JdbcTemplate jdbc = createJdbc();
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L)))
.thenReturn(List.of());
ReconciliationStatementService target = serviceWithNumber(jdbc);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
@@ -90,7 +94,15 @@ class ReconciliationStatementBehaviorTest {
var result = target.create(71L, "cutoff-key", request);
assertThat(result.status()).isEqualTo("DRAFT");
assertThat(result.customerName()).isEqualTo("测试客户公司");
assertThat(result.totalReceivable()).isEqualByComparingTo("100.00");
var creator = ArgumentCaptor.forClass(PreparedStatementCreator.class);
verify(jdbc).update(creator.capture(), any(KeyHolder.class));
var connection = mock(java.sql.Connection.class);
var insert = mock(java.sql.PreparedStatement.class);
when(connection.prepareStatement(anyString(), eq(java.sql.Statement.RETURN_GENERATED_KEYS))).thenReturn(insert);
creator.getValue().createPreparedStatement(connection);
verify(insert).setString(4, "测试客户公司");
verify(jdbc).query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class),
eq(9L), eq(LocalDate.of(2026, 8, 1)), eq(LocalDate.of(2026, 8, 31)));
}
@@ -350,6 +362,29 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="), (Object[]) any());
}
/** 首次操作响应必须带上本次审计修订,不能要求页面刷新后才看到最新操作。 */
@ParameterizedTest
@ValueSource(strings = {"SUBMIT", "ADJUST"})
void actionResponseIncludesRevisionWrittenByThatAction(String action) throws Exception {
boolean adjust = "ADJUST".equals(action);
JdbcTemplate jdbc = actionJdbc(adjust ? "ISSUED" : "DRAFT", adjust ? "ISSUED" : "PENDING_REVIEW", "revision-key");
var auditWritten = new java.util.concurrent.atomic.AtomicBoolean();
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_audit_log"), any(Object[].class)))
.thenAnswer(invocation -> { auditWritten.set(true); return 1; });
var revision = new com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision(
1, action, new BigDecimal("240.00"), new BigDecimal(adjust ? "280.00" : "240.00"),
"核对即时修订", 71L, "测试财务", LocalDateTime.of(2026, 10, 27, 12, 0));
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L)))
.thenAnswer(invocation -> auditWritten.get() ? List.of(revision) : List.of());
if (adjust) when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
.thenReturn(new BigDecimal("280.00"));
var request = new ReconciliationActionRequest(action, "核对即时修订", adjust ? 3 : 0,
adjust ? List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "调整")) : List.of());
var result = serviceWithNumber(jdbc).action(71L, 501L, "revision-key", request);
assertThat(auditWritten.get()).isTrue();
assertThat(result.revisions()).containsExactly(revision);
}
@Test
void voidIssuedStatementReleasesSourcesWhenNoReceiptOrSettlementExists() throws Exception {
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
@@ -589,9 +624,30 @@ class ReconciliationStatementBehaviorTest {
};
}
/** 审批前后使用不同 DTO,实际序列化后的详情金额必须完整返回而非变成零。 */
@Test
void revisionReadsBeforeBillAndAfterDetailAmountsAndRejectsMissingAmount() throws Exception {
var service = serviceWithNumber(mock(JdbcTemplate.class));
var rs = mock(ResultSet.class);
when(rs.getString("action")).thenReturn("RECONCILIATION_ADJUST");
when(rs.getString("before_data")).thenReturn("{\"total\":240.00}");
when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":240.03}");
when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-10-27 10:00:00"));
com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision revision =
ReflectionTestUtils.invokeMethod(service, "revision", rs, 1);
assertThat(revision.beforeAmount()).isEqualByComparingTo("240.00");
assertThat(revision.afterAmount()).isEqualByComparingTo("240.03");
when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":9999999999999999.99}");
revision = ReflectionTestUtils.invokeMethod(service, "revision", rs, 1);
assertThat(revision.afterAmount()).isEqualByComparingTo("9999999999999999.99");
when(rs.getString("after_data")).thenReturn("{}");
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "revision", rs, 1))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("快照损坏");
}
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001");
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers, FinanceCustomerNamesTestSupport.names());
}
}
@@ -85,6 +85,26 @@ class FinanceExportProviderServiceTest {
assertThat(continued.snapshotToken()).isEqualTo(first.snapshotToken());
}
@Test
void exportsPaidSourcesUsingTheSameProjectionAndFiltersAsTheList() throws Exception {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var source = mapper.treeToValue(mapper.createObjectNode().put("feeSourceId", 81).put("sourceFeeId", 9001)
.put("sourceVersion", 2).put("sourceSystem", "FIRSTLEG").put("sourceType", "CONTAINER_FEE")
.put("direction", "PAYABLE").put("customerId", 10001).put("customerName", "客户A")
.put("feeItemName", "装车费").put("amount", 300).put("currency", "CNY")
.put("reviewStatus", "APPROVED").put("sourceStatus", "PAID").put("statementGenerated", false),
com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary.class);
when(operationService.listFinanceFeeSources(1, 200, null, "PAYABLE", "PAID", null, null, null, null, null))
.thenReturn(PageEnvelope.success(List.of(source), 1, 200, 1));
var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST,
request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null));
assertThat(result.rows()).singleElement().satisfies(row -> {
assertThat(row).containsEntry("sourceStatus", "PAID").containsEntry("sourceFeeId", "9001");
assertThat(row.get("amount")).isEqualTo(new BigDecimal("300"));
});
org.mockito.Mockito.verifyNoInteractions(queryService);
}
@Test
void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() {
assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,
+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` |
| 称重确认与整票库位块 | `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/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
| 客户四档箱规与默认算法 | `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`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;`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/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` |
@@ -76,7 +76,7 @@
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `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 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`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`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
@@ -7,6 +7,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
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.ManualQuoteRequest;
import com.baiyue.firstleg.service.FbaPricingService;
@@ -34,6 +36,16 @@ public class PlatformFbaPricingController {
@Resource
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")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
@@ -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(
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
@@ -34,6 +36,7 @@ public class InboundMeasurementItemEntity {
private BigDecimal volumeCbm;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long chargeRuleId;
private String coefficientMode;
private String calculationRuleVersion;
@@ -44,8 +47,12 @@ public class InboundMeasurementItemEntity {
private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm;
// 清空修正表示恢复对应实测边,必须显式写入 NULL,不能被默认的非空更新策略忽略。
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterLengthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterWidthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterHeightCm;
private String remark;
private LocalDateTime createdAt;
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
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,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
private String locationName;
private String sourceReference;
private String rawPayloadHash;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -11,14 +11,15 @@ import java.util.List;
@Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
/** 保存不可变节点事实及执行该操作的账号快照。 */
@Insert("""
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,
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
(#{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.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
CURRENT_TIMESTAMP(3))
""")
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
public long customerId(long id) { return required(id).getCustomerId(); }
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
/** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
@Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId,
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "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) {
persistenceStage = "CROSS_REGION_OUTBOX";
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
}
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
@Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node,
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) {
return detail(value);
}
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
return detail(value);
}
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
@Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) {
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(),
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) {
return complete(id, operatorId, key, detail(value));
}
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
@Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key,
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId,
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) {
CarrierScheduleQueryEntity before = copy(query);
requireManualActualAdvance(query, node, request.actualAt());
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
repository.histories(value.getId()).stream().map(this::history).toList());
}
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
}
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
}
/** 为查询任务追加节点事实并透传人工操作账号。 */
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
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,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash);
sourceReference, rawPayloadHash, operatorId, operatorName);
}
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
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.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
@@ -204,13 +204,15 @@ public final class SailingCalendarDtos {
int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm,
BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm,
BigDecimal availableBookedCapacityCbm) {}
public record CalendarResult(String month, String timezone, List<CalendarItem> records,
Summary summary, FilterOptions filterOptions) {
Summary summary, FilterOptions filterOptions, LocalDateTime nextRefreshAt) {
public record Summary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, BigDecimal riskUnmatchedCbm,
int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int sailingCount,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm,
BigDecimal availableBookedCapacityCbm) {}
public record FilterOptions(List<Option> warehouses, List<Option> channels, List<Option> departurePorts, List<Option> destinationPorts) {}
public record Option(String value, String label) {}
}
@@ -1,83 +1,172 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 船期日历的只读展示投影。
*
* 这里直接读取现有入库审核结果,不写入船期事实表,也不触发归班、改绑或智能排柜。
* 主子入库单只统计未拆分主单或仍可用的余量子单,避免把父单和子单重复计入。
*/
/** 船期日历的只读投影;保留有效运输安排,未安排余量自然顺延,不改写业务归属。 */
@Mapper
public interface SailingCalendarDisplayMapper {
// 显式关联优先;无显式关联才按运输信息匹配。每条订舱只对应一个有效船期,
// 已确认货物允许保留在已截仓但尚未过期的船期,不能因为截仓就再次建议订柜。
// 跨表文本按字段完整长度转换为连接字符集,避免表间排序规则冲突,同时保留完整运输快照。
String BOOKING_TARGETS = """
SELECT b.id AS booking_id, (
SELECT s.id FROM fl_sailing s
WHERE s.etd_at >= #{todayStart} AND s.status IN ('OPEN', 'CLOSED')
AND (
EXISTS (SELECT 1 FROM fl_sailing_booking_link link
WHERE link.sailing_id = s.id AND link.ocean_booking_id = b.id
AND link.link_status = 'ACTIVE')
OR (
NOT EXISTS (SELECT 1 FROM fl_sailing_booking_link link
WHERE link.ocean_booking_id = b.id AND link.link_status = 'ACTIVE')
AND b.warehouse_id = s.warehouse_id
AND CAST(b.channel_code AS CHAR(64)) = CAST(s.channel_code_snapshot AS CHAR(64))
AND CAST(b.departure_port_code AS CHAR(32)) = CAST(s.departure_port_code AS CHAR(32))
AND CAST(b.destination_port_code AS CHAR(32)) = CAST(s.destination_port_code AS CHAR(32))
AND b.vessel_name IS NOT NULL AND b.voyage_no IS NOT NULL
AND CAST(b.vessel_name AS CHAR(128)) = CAST(s.vessel_name AS CHAR(128))
AND CAST(b.voyage_no AS CHAR(64)) = CAST(s.voyage_no AS CHAR(64))
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(s.etd_at))
)
)
ORDER BY s.etd_at, s.sailing_no, s.id LIMIT 1
) AS sailing_id
FROM fl_ocean_booking b WHERE b.status = 'RELEASED'
""";
// 普通箱分配可能在测量前创建,体积必须按最新已审核箱规重算;全量分配恰好等于实测,
// 按累计箱数分摊四位小数尾差,避免余下极小体积被向上取整为额外一柜。
// 已封托整托的体积是现场物理托盘快照,仅主明细保存体积,不能按多 SKU 行重复分摊。
String MEASURED_PLAN_ITEMS = """
SELECT quantities.id, quantities.loading_plan_id, quantities.account_id, quantities.inbound_id,
CASE WHEN quantities.unit_type = 'SEALED_PALLET' THEN quantities.pallet_volume_cbm
ELSE ROUND(quantities.measured_cbm * quantities.cumulative_boxes
/ NULLIF(GREATEST(quantities.measured_boxes, quantities.allocated_boxes), 0), 4)
- ROUND(quantities.measured_cbm * (quantities.cumulative_boxes - quantities.package_count)
/ NULLIF(GREATEST(quantities.measured_boxes, quantities.allocated_boxes), 0), 4)
END AS volume_cbm
FROM (
SELECT item.id, item.loading_plan_id, item.account_id, item.inbound_id, item.unit_type,
item.package_count, item.volume_cbm AS pallet_volume_cbm,
measured.package_count AS measured_boxes, measured.volume_cbm AS measured_cbm,
SUM(item.package_count) OVER (
PARTITION BY item.account_id, item.inbound_id, item.inbound_detail_id, item.unit_type
) AS allocated_boxes,
SUM(item.package_count) OVER (
PARTITION BY item.account_id, item.inbound_id, item.inbound_detail_id, item.unit_type
ORDER BY item.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_boxes
FROM fl_loading_plan_item item
JOIN fl_inbound_measurement batch
ON batch.tenant_id = item.account_id AND batch.inbound_id = item.inbound_id
AND batch.status = 'APPROVED'
AND batch.id = (SELECT MAX(latest.id) FROM fl_inbound_measurement latest
WHERE latest.tenant_id = item.account_id AND latest.inbound_id = item.inbound_id
AND latest.status = 'APPROVED')
LEFT JOIN (
SELECT tenant_id, measurement_id, inbound_detail_id,
SUM(package_count) AS package_count, SUM(total_volume_cbm) AS volume_cbm
FROM fl_inbound_measurement_item WHERE package_count > 0
GROUP BY tenant_id, measurement_id, inbound_detail_id
) measured ON measured.tenant_id = item.account_id AND measured.measurement_id = batch.id
AND measured.inbound_detail_id = item.inbound_detail_id
WHERE item.deleted = 0
) quantities
""";
/** 已订柜总数仍展示原订舱需求,可用容量按柜扣除已确认离仓部分,避免余货少订。 */
@Select("""
SELECT
COALESCE(SUM(r.quantity), 0) AS containerCount,
COALESCE(SUM(COALESCE(r.capacity_cbm, 0) * r.quantity), 0) AS capacityCbm
FROM fl_ocean_booking b
JOIN fl_ocean_booking_container_requirement r ON r.ocean_booking_id = b.id
WHERE b.status = 'RELEASED'
AND (
EXISTS (
SELECT 1 FROM fl_sailing_booking_link link
WHERE link.sailing_id = #{sailingId}
AND link.ocean_booking_id = b.id
AND link.link_status = 'ACTIVE'
)
OR (
b.warehouse_id = #{warehouseId}
AND b.channel_code = #{channelCode}
AND b.departure_port_code = #{departurePortCode}
AND b.destination_port_code = #{destinationPortCode}
AND (#{vesselName} IS NOT NULL AND #{voyageNo} IS NOT NULL
AND b.vessel_name = #{vesselName}
AND b.voyage_no = #{voyageNo}
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(#{etdAt})))
)
)
SELECT COALESCE(SUM(r.quantity), 0) AS containerCount,
COALESCE(SUM(r.capacity_cbm * r.quantity), 0) AS capacityCbm,
COALESCE(SUM(r.capacity_cbm * GREATEST(r.quantity - COALESCE(d.departed_count, 0), 0)), 0) AS availableCapacityCbm
FROM (
""" + BOOKING_TARGETS + """
) target
JOIN fl_ocean_booking_container_requirement r ON r.ocean_booking_id = target.booking_id
LEFT JOIN (
SELECT allocation.ocean_booking_id, allocation.requirement_id,
COUNT(DISTINCT allocation.container_booking_id) AS departed_count
FROM fl_ocean_booking_container_allocation allocation
WHERE EXISTS (SELECT 1 FROM fl_outbound_dispatch dispatch
WHERE dispatch.container_booking_id = allocation.container_booking_id
AND dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL)
GROUP BY allocation.ocean_booking_id, allocation.requirement_id
) d ON d.ocean_booking_id = r.ocean_booking_id AND d.requirement_id = r.id
WHERE target.sailing_id = #{sailingId}
""")
BookingSummary bookingSummary(@Param("sailingId") long sailingId,
BookingSummary bookingSummary(@Param("sailingId") long sailingId, @Param("todayStart") LocalDateTime todayStart);
/** 按审核实测重算分配,跨表渠道统一文本排序规则后归入唯一船期,保持待发体积守恒。 */
@Select("""
SELECT COALESCE(SUM(CASE WHEN receipt_at <= #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS catchableCbm,
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS lateCbm,
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN 1 ELSE 0 END), 0) AS lateCount
FROM (
SELECT COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) AS receipt_at,
COALESCE(allocated.current_cbm, 0) + CASE WHEN #{sailingId} = (
SELECT next_sailing.id FROM fl_sailing next_sailing
WHERE next_sailing.warehouse_id = o.warehouse_id
AND CAST(next_sailing.channel_code_snapshot AS CHAR(64)) = CAST(o.channel_code AS CHAR(64))
AND next_sailing.status = 'OPEN'
AND next_sailing.etd_at >= #{todayStart}
AND next_sailing.warehouse_cutoff_at >= #{asOf}
ORDER BY next_sailing.etd_at, next_sailing.sailing_no, next_sailing.id LIMIT 1
) THEN GREATEST(o.actual_volume_cbm - COALESCE(allocated.reserved_cbm, 0), 0) ELSE 0 END AS volume_cbm
FROM fl_inbound_order o
LEFT JOIN (
SELECT item.account_id, item.inbound_id,
SUM(CASE WHEN (dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL)
OR target.sailing_id IS NOT NULL THEN item.volume_cbm ELSE 0 END) AS reserved_cbm,
SUM(CASE WHEN dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL THEN 0
WHEN target.sailing_id = #{sailingId} THEN item.volume_cbm ELSE 0 END) AS current_cbm
FROM (
""" + MEASURED_PLAN_ITEMS + """
) item
JOIN fl_loading_plan plan ON plan.id = item.loading_plan_id
JOIN fl_inbound_order source ON source.tenant_id = item.account_id AND source.id = item.inbound_id
LEFT JOIN fl_outbound_dispatch dispatch
ON dispatch.tenant_id = item.account_id AND dispatch.inbound_id = item.inbound_id
AND dispatch.container_booking_id = plan.container_booking_id
LEFT JOIN fl_ocean_booking_container_allocation allocation ON allocation.container_booking_id = plan.container_booking_id
LEFT JOIN (
""" + BOOKING_TARGETS + """
) target ON target.booking_id = allocation.ocean_booking_id
WHERE ((source.warehouse_id = #{warehouseId} AND source.channel_code = #{channelCode})
OR target.sailing_id = #{sailingId})
GROUP BY item.account_id, item.inbound_id
) allocated ON allocated.account_id = o.tenant_id AND allocated.inbound_id = o.id
WHERE o.deleted = 0 AND o.measure_review_status = 'APPROVED'
AND o.process_status IN ('WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED')
AND ((o.warehouse_id = #{warehouseId} AND o.channel_code = #{channelCode}) OR allocated.current_cbm > 0)
AND (COALESCE(o.split_status, 'NOT_SPLIT') = 'NOT_SPLIT'
OR (o.split_status = 'FULLY_SPLIT' AND o.process_status IN ('LOADING', 'LOADED')))
AND o.actual_volume_cbm IS NOT NULL
AND COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) <= #{asOf}
) received WHERE volume_cbm > 0
""")
ReceiptSummary receiptSummary(@Param("sailingId") long sailingId,
@Param("warehouseId") long warehouseId,
@Param("channelCode") String channelCode,
@Param("departurePortCode") String departurePortCode,
@Param("destinationPortCode") String destinationPortCode,
@Param("vesselName") String vesselName,
@Param("voyageNo") String voyageNo,
@Param("etdAt") java.time.LocalDateTime etdAt);
@Param("cutoffAt") LocalDateTime cutoffAt,
@Param("asOf") LocalDateTime asOf,
@Param("todayStart") LocalDateTime todayStart);
/** 截仓节点跨月份、船名和港口筛选查询,避免隐藏的前班截仓后当前货量不刷新。 */
@Select("""
SELECT
COALESCE(SUM(CASE WHEN receipt_at <= #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS catchableCbm,
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS lateCbm,
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN 1 ELSE 0 END), 0) AS lateCount
FROM (
SELECT
COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) AS receipt_at,
COALESCE(o.actual_volume_cbm, 0) AS volume_cbm
FROM fl_inbound_order o
WHERE o.deleted = 0
AND o.measure_review_status = 'APPROVED'
AND o.warehouse_id = #{warehouseId}
AND o.channel_code = #{channelCode}
AND (
(o.inbound_role = 'MASTER' AND COALESCE(o.split_status, 'NOT_SPLIT') = 'NOT_SPLIT')
OR (o.inbound_role = 'CHILD' AND o.split_purpose = 'REMAINDER' AND o.split_status = 'NOT_SPLIT')
)
AND o.actual_volume_cbm IS NOT NULL
AND (#{fromAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) > #{fromAt})
AND (#{toAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) <= #{toAt})
) received
SELECT MIN(warehouse_cutoff_at) FROM fl_sailing
WHERE status = 'OPEN' AND etd_at >= #{todayStart} AND warehouse_cutoff_at >= #{asOf}
AND (#{query.warehouseId} IS NULL OR warehouse_id = #{query.warehouseId})
AND (#{query.channelCode} IS NULL OR channel_code_snapshot = #{query.channelCode})
""")
ReceiptSummary receiptSummary(@Param("warehouseId") long warehouseId,
@Param("channelCode") String channelCode,
@Param("cutoffAt") java.time.LocalDateTime cutoffAt,
@Param("fromAt") java.time.LocalDateTime fromAt,
@Param("toAt") java.time.LocalDateTime toAt);
LocalDateTime nextCutoff(@Param("query") SailingQuery query, @Param("asOf") LocalDateTime asOf,
@Param("todayStart") LocalDateTime todayStart);
record ReceiptSummary(BigDecimal catchableCbm, BigDecimal lateCbm, Integer lateCount) {}
record BookingSummary(Integer containerCount, BigDecimal capacityCbm) {}
record BookingSummary(Integer containerCount, BigDecimal capacityCbm, BigDecimal availableCapacityCbm) {}
}
@@ -132,13 +132,17 @@ public class SailingCalendarRepository {
}
public OceanBookingEntity booking(long id) { return oceanBookings.find(id); }
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return oceanBookings.requirements(id); }
public SailingCalendarDisplayMapper.ReceiptSummary receiptSummary(SailingEntity sailing) {
return display.receiptSummary(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
sailing.getWarehouseCutoffAt(), previousCutoff(sailing), nextCutoff(sailing));
/** 按当前时刻查询未离仓货量的下一有效船期展示投影,不写入货物或订舱归属。 */
public SailingCalendarDisplayMapper.ReceiptSummary receiptSummary(SailingEntity sailing, LocalDateTime asOf) {
return display.receiptSummary(sailing.getId(), sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
sailing.getWarehouseCutoffAt(), asOf, asOf.toLocalDate().atStartOfDay());
}
public SailingCalendarDisplayMapper.BookingSummary bookingSummary(SailingEntity sailing) {
return display.bookingSummary(sailing.getId(), sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getVesselName(),
sailing.getVoyageNo(), sailing.getEtdAt());
/** 订舱总需求和未离仓可用容量分别返回,使用与货量投影相同的自然日。 */
public SailingCalendarDisplayMapper.BookingSummary bookingSummary(SailingEntity sailing, LocalDateTime asOf) {
return display.bookingSummary(sailing.getId(), asOf.toLocalDate().atStartOfDay());
}
/** 查找会影响当前仓库、渠道展示的下一截仓时刻,范围不受所选月份限制。 */
public LocalDateTime nextCutoff(SailingQuery query, LocalDateTime asOf) {
return display.nextCutoff(query, asOf, asOf.toLocalDate().atStartOfDay());
}
}
@@ -17,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -43,6 +44,7 @@ public class SailingCalendarService {
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private Clock clock = Clock.system(ZONE);
private static final String OPEN = "OPEN";
@Resource private SailingCalendarRepository repository;
@Resource private PriceBookQueryUseCase priceBooks;
@@ -93,12 +95,17 @@ public class SailingCalendarService {
return requiredTemplate(id);
}
/** 月历和汇总只纳入上海时区今天及之后开船的计划,历史计划按日期自然过期。 */
public CalendarResult calendar(SailingQuery query) {
LocalDate first;
try { first = LocalDate.parse(query.month() + "-01"); } catch (RuntimeException ex) { throw bad("month必须为yyyy-MM"); }
LocalDate next = first.plusMonths(1);
List<SailingEntity> values = repository.calendar(query, first.atStartOfDay(), next.atStartOfDay());
List<CalendarItem> items = values.stream().map(this::item).toList();
LocalDateTime asOf = LocalDateTime.now(clock.withZone(ZONE));
LocalDate today = asOf.toLocalDate();
LocalDate start = first.isBefore(today) ? today : first;
List<SailingEntity> values = start.isBefore(next)
? repository.calendar(query, start.atStartOfDay(), next.atStartOfDay()) : List.of();
List<CalendarItem> items = values.stream().map(value -> item(value, asOf)).toList();
BigDecimal capacity = items.stream().map(v -> v.volumeSummary().planningCapacityCbm() == null ? BigDecimal.ZERO : v.volumeSummary().planningCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal actual = items.stream().map(v -> v.volumeSummary().actualReceivedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal forecast = items.stream().map(v -> v.volumeSummary().catchableForecastCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
@@ -109,17 +116,23 @@ public class SailingCalendarService {
int late = items.stream().mapToInt(v -> v.volumeSummary().lateBatchCount()).sum();
BigDecimal catchableReceived = items.stream().map(v -> nvl(v.volumeSummary().catchableReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal lateReceived = items.stream().map(v -> nvl(v.volumeSummary().lateReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived);
BigDecimal availableBookedCapacity = items.stream().map(v -> v.volumeSummary().availableBookedCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived, availableBookedCapacity);
LocalDateTime nextRefreshAt = today.plusDays(1).atStartOfDay();
LocalDateTime nextCutoff = start.isBefore(next) ? repository.nextCutoff(query, asOf) : null;
// 截止时间仍包含该毫秒,跨过后再刷新;隐藏月份和筛选之外的前班也参与调度。
if (nextCutoff != null && nextCutoff.plusNanos(1_000_000).isBefore(nextRefreshAt)) nextRefreshAt = nextCutoff.plusNanos(1_000_000);
return new CalendarResult(query.month(), ZONE.getId(), items, summary,
new CalendarResult.FilterOptions(
values.stream().map(v -> new CalendarResult.Option(String.valueOf(v.getWarehouseId()), v.getWarehouseNameSnapshot())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getChannelCodeSnapshot(), v.getChannelNameSnapshot())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getDeparturePortCode(), v.getDeparturePortCode())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList()));
values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList()), nextRefreshAt);
}
/** 单次详情使用同一上海业务时刻读取船期货量。 */
public SailingDetail detail(long id) {
SailingEntity value = requiredSailing(id);
return new SailingDetail(item(value), history(id));
return new SailingDetail(item(value, LocalDateTime.now(clock.withZone(ZONE))), history(id));
}
public List<SailingHistoryView> history(long id) {
requiredSailing(id);
@@ -321,6 +334,7 @@ public class SailingCalendarService {
}
recordHistory(value.getId(), "COPY", null, OPEN, copyReason, operatorId, operatorName, value.getCreatedAt()); return detail(value.getId());
}
/** 返回当前未离仓货量投影及已有归属记录,不创建新归属。 */
public VolumeDetail volumeSummary(long id) {
SailingEntity value = requiredSailing(id);
List<AssignmentView> assignments = repository.assignments(id).stream().map(a -> {
@@ -328,20 +342,20 @@ public class SailingCalendarService {
? null : repository.receiptFact(a.getReceiptBatchId()).getReceivedAt();
return new AssignmentView(String.valueOf(a.getId()), a.getReceiptBatchId() == null ? null : String.valueOf(a.getReceiptBatchId()), a.getForecastId() == null ? null : String.valueOf(a.getForecastId()), a.getInboundCode(), receivedAt, nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), a.getMatchStatus(), a.getAssignmentSource(), a.getReason(), a.getAssignmentVersion());
}).toList();
return new VolumeDetail(summary(value), assignments);
return new VolumeDetail(summary(value, LocalDateTime.now(clock.withZone(ZONE))), assignments);
}
/** 按全部已收和可核验预计货量向上取整,晚到量保留风险提示但不漏算订柜需求。 */
public BookingAdvice bookingAdvice(long id) {
SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value);
BigDecimal catchable = catchableVolume(value.getId());
int recommended = recommendedContainers(catchable);
BigDecimal bookedCapacity = bookedPlanningCapacity(value.getId());
int pending = ceil(nvl(catchable.subtract(bookedCapacity)), defaultContainerCapacity());
SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value, LocalDateTime.now(clock.withZone(ZONE)));
BigDecimal catchable = s.catchableReceivedCbm().add(s.catchableForecastCbm());
BigDecimal demand = s.actualReceivedCbm().add(s.catchableForecastCbm());
BigDecimal shortage = demand.subtract(s.availableBookedCapacityCbm());
List<BookingScenario> scenarios = ContainerSpecificationCatalog.values().stream().map(spec -> {
int count = ceil(catchable, spec.capacityCbm());
int pendingForType = ceil(nvl(catchable.subtract(bookedCapacity)), spec.capacityCbm());
int count = ceil(demand, spec.capacityCbm());
int pendingForType = ceil(shortage, spec.capacityCbm());
return new BookingScenario(spec.containerType(), spec.containerType(), spec.capacityCbm(), count, pendingForType);
}).toList();
return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), recommended, pending, scenarios, LocalDateTime.now(ZONE), s.catchableReceivedCbm(), s.lateReceivedCbm());
return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), s.recommendedContainerCount(), s.pendingContainerCount(), scenarios, s.snapshotAt(), s.catchableReceivedCbm(), s.lateReceivedCbm());
}
@Transactional
public Map<String, Object> linkBooking(long id, long operatorId, String operatorName, BookingLinkRequest request) {
@@ -410,9 +424,11 @@ public class SailingCalendarService {
private void recordHistory(long sailingId, String action, String fromStatus, String toStatus, String reason, long operatorId, String operatorName, LocalDateTime at) {
SailingHistoryEntity history = new SailingHistoryEntity(); history.setId(IdWorker.getId()); history.setSailingId(sailingId); history.setAction(action); history.setFromStatus(fromStatus); history.setToStatus(toStatus); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setCreatedAt(at); repository.insertHistory(history);
}
private CalendarItem item(SailingEntity v) { return new CalendarItem(v.getId(), v.getSailingNo(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getVesselName(), v.getVoyageNo(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getEtdAt(), v.getWarehouseCutoffAt(), v.getStatus(), v.getVersion(), summary(v), v.getTemplateId(), v.getChannelId(), v.getEtaAt(), v.getSourceType(), v.getPlannedCapacityCbm()); }
private VolumeSummary summary(SailingEntity v) {
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v);
/** 整次月历共用查询时刻,避免跨过截止瞬间时把同一货量计入两班。 */
private CalendarItem item(SailingEntity v, LocalDateTime asOf) { return new CalendarItem(v.getId(), v.getSailingNo(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getVesselName(), v.getVoyageNo(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getEtdAt(), v.getWarehouseCutoffAt(), v.getStatus(), v.getVersion(), summary(v, asOf), v.getTemplateId(), v.getChannelId(), v.getEtaAt(), v.getSourceType(), v.getPlannedCapacityCbm()); }
/** 统一日历、详情与订柜初算的货量和已订容量口径。 */
private VolumeSummary summary(SailingEntity v, LocalDateTime asOf) {
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v, asOf);
BigDecimal eligibleActual = received == null ? BigDecimal.ZERO : nvl(received.catchableCbm());
BigDecimal lateActual = received == null ? BigDecimal.ZERO : nvl(received.lateCbm());
BigDecimal actual = eligibleActual.add(lateActual);
@@ -420,12 +436,14 @@ public class SailingCalendarService {
// 当前展示版不再产生“未归班”事实;没有可靠的预计体积源时不把它冒充成待确认货量。
BigDecimal risk = BigDecimal.ZERO;
int late = received == null || received.lateCount() == null ? 0 : received.lateCount();
BookingTotals bookedTotals = bookingTotals(v);
BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = catchableVolume(v.getId());
BookingTotals bookedTotals = bookingTotals(v, asOf);
BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = eligibleActual.add(forecast);
boolean over = capacity != null && catchable.compareTo(capacity) > 0;
int recommended = recommendedContainers(catchable);
int pending = ceil(nvl(catchable.subtract(bookedTotals.capacityCbm())), defaultContainerCapacity());
return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), LocalDateTime.now(ZONE), eligibleActual, lateActual);
// 截止时间仅拆分参考量与风险量;有实收货量就按统一柜型容量向上取整。
BigDecimal demand = actual.add(forecast);
int recommended = recommendedContainers(demand);
int pending = ceil(demand.subtract(bookedTotals.availableCapacityCbm()), defaultContainerCapacity());
return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), asOf, eligibleActual, lateActual, bookedTotals.availableCapacityCbm());
}
private static int recommendedContainers(BigDecimal volume) { return ceil(volume, defaultContainerCapacity()); }
private static BigDecimal defaultContainerCapacity() {
@@ -435,18 +453,14 @@ public class SailingCalendarService {
.findFirst()
.orElseThrow(() -> new IllegalStateException("默认柜型未配置"));
}
private BigDecimal catchableVolume(long sailingId) {
SailingEntity sailing = requiredSailing(sailingId);
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(sailing);
return received == null ? BigDecimal.ZERO : nvl(received.catchableCbm());
}
private BigDecimal bookedPlanningCapacity(long sailingId) { return bookingTotals(requiredSailing(sailingId)).capacityCbm(); }
private BookingTotals bookingTotals(SailingEntity sailing) {
SailingCalendarDisplayMapper.BookingSummary value = repository.bookingSummary(sailing);
/** 已订总量用于展示,仍可承接货物的容量才参与待补柜扣减。 */
private BookingTotals bookingTotals(SailingEntity sailing, LocalDateTime asOf) {
SailingCalendarDisplayMapper.BookingSummary value = repository.bookingSummary(sailing, asOf);
return new BookingTotals(value == null || value.containerCount() == null ? 0 : value.containerCount(),
value == null ? BigDecimal.ZERO : nvl(value.capacityCbm()));
value == null ? BigDecimal.ZERO : nvl(value.capacityCbm()),
value == null ? BigDecimal.ZERO : nvl(value.availableCapacityCbm()));
}
private record BookingTotals(int containerCount, BigDecimal capacityCbm) {}
private record BookingTotals(int containerCount, BigDecimal capacityCbm, BigDecimal availableCapacityCbm) {}
private static int ceil(BigDecimal volume, BigDecimal unit) { return volume == null || volume.signum() <= 0 ? 0 : volume.divide(unit, 0, java.math.RoundingMode.CEILING).intValue(); }
private static BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private static void copy(SailingChannelEntity v, ChannelData d) { v.setChannelCode(d.channelCode().trim().toUpperCase()); v.setChannelName(d.channelName().trim()); v.setSourceType(d.sourceType().trim().toUpperCase()); v.setMappedChannelCode(trimUpper(d.mappedChannelCode())); v.setDefaultDeparturePortCode(trimUpper(d.defaultDeparturePortCode())); v.setDefaultDestinationPortCode(trimUpper(d.defaultDestinationPortCode())); v.setDefaultCutoffDays(d.defaultCutoffDays() == null ? 3 : d.defaultCutoffDays()); v.setRemark(trim(d.remark())); }
@@ -44,21 +44,22 @@ public class CartonMeasurementService {
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
private final MeasurementImageClient imageClient;
private final TransactionTemplate transactionTemplate;
private final CustomerDimensionWeightingService dimensionWeighting;
private final MeasurementBatchService measurementBatchService;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
/** 设备及手工单箱录入复用测量批次的计算规则。 */
public CartonMeasurementService(InboundMeasurementRepository repository,
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
MeasurementImageClient imageClient,
TransactionTemplate transactionTemplate,
CustomerDimensionWeightingService dimensionWeighting) {
MeasurementBatchService measurementBatchService) {
this.repository = repository;
this.resolverProvider = resolverProvider;
this.imageClient = imageClient;
this.transactionTemplate = transactionTemplate;
this.dimensionWeighting = dimensionWeighting;
this.measurementBatchService = measurementBatchService;
}
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
@@ -178,6 +179,7 @@ public class CartonMeasurementService {
return result(measurement, item, false);
}
/** 保存设备原始尺寸与证据,按客户默认算法或已保存的行算法生成计费快照。 */
private UpsertOutcome persist(long tenantId, long operatorId,
CartonLabelBindingResolver.Binding binding,
String boxCode, CartonMeasurementCommand command, long imageFileId,
@@ -228,7 +230,7 @@ public class CartonMeasurementService {
item.setMachineNo(command.machineNo().trim());
item.setDeviceMeasuredAt(command.measuredAt());
item.setUpdatedAt(LocalDateTime.now());
applyCalculatedValues(item, dimensionWeighting.load(tenantId));
measurementBatchService.calculateCartonDimensions(item);
if (created) {
repository.insertItem(item);
@@ -265,33 +267,12 @@ public class CartonMeasurementService {
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) {
if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm())
|| !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm())
|| !StringUtils.hasText(item.getCalculationRuleVersion())) {
applyCalculatedValues(item, dimensionWeighting.load(item.getTenantId()));
measurementBatchService.calculateCartonDimensions(item);
return;
}
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
@@ -25,6 +25,7 @@ public class CustomerDimensionWeightingService {
this.repository = repository;
}
/** 加载完整四档箱规及客户明确选择的默认算法,配置存在不代表算法已启用。 */
public RuleSet load(long tenantId) {
List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId);
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) {
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) {
@@ -48,14 +56,22 @@ public class CustomerDimensionWeightingService {
public static final class RuleSet {
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()
.sorted(Comparator.comparing(Rule::innerVolume)
.thenComparing(rule -> rule.boxType().ordinal()))
.toList();
}
/** 返回客户已启用的默认算法;ACTUAL 表示尺寸不调整。 */
public String defaultCalculationMode() {
return defaultCalculationMode;
}
public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) {
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
return rules.stream()
@@ -66,24 +82,6 @@ public class CustomerDimensionWeightingService {
.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(
@@ -5,6 +5,8 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
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.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
@@ -63,6 +65,41 @@ public class FbaPricingService {
@Resource
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());
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;
}
BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363");
return new FeePreviewResponse(weight, volume, manualChargeableQuantity(policy.chargeMode(), weight, volume),
policy.chargeMode(), factor, 6, "HALF_UP", null,
BigDecimal.ZERO.setScale(MONEY_SCALE), request.selectedUnitPrice() == null ? null
: manualChargeableQuantity(policy.chargeMode(), weight, volume).multiply(request.selectedUnitPrice())
.setScale(MONEY_SCALE, RoundingMode.HALF_UP), "CNY", false);
}
}
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) {
@@ -77,6 +114,7 @@ public class FbaPricingService {
selectedVolumeCbm, null, null, idempotencyKey);
}
/** 复用预览的计价规则生成费用版本,仅正式确认流程写入计费事实。 */
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, BigDecimal selectedWeightKg,
@@ -105,38 +143,11 @@ public class FbaPricingService {
throw conflict("客户已确认,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
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("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
var measurement = requireMeasurementSources(tenantId, inboundId, weightSource, volumeSource);
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
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(),
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);
BigDecimal weight = selectedWeight(inbound, selectedWeightKg);
BigDecimal volume = selectedVolume(inbound, selectedVolumeCbm);
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
RateRow rate = estimate.rate();
BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable;
@@ -179,6 +190,7 @@ public class FbaPricingService {
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
}
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
@Transactional
public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId,
ManualQuoteRequest request, String idempotencyKey) {
@@ -219,9 +231,7 @@ public class FbaPricingService {
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
String unit = policy.chargeMode();
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
BigDecimal quantity = ("KG".equals(unit) ? weight.max(volume.multiply(factor))
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
.setScale(6, RoundingMode.HALF_UP);
BigDecimal quantity = manualChargeableQuantity(unit, weight, volume);
BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
int version = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity fee = new InboundFeeEntity();
@@ -267,6 +277,61 @@ public class FbaPricingService {
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(
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
BigDecimal currentWeight, BigDecimal currentVolume) {
@@ -56,6 +56,7 @@ public class MeasurementBatchService {
@Resource
private FbaPricingRepository pricingRepository;
/** 按已选算法保存测量批次及计算快照,不推进履约状态。 */
@Transactional
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
var inbound = repository.findInbound(command.tenantId(), command.inboundId());
@@ -68,7 +69,7 @@ public class MeasurementBatchService {
}
validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId()));
command.tenantId(), command.items(), dimensionWeighting.load(command.tenantId()));
InboundMeasurementEntity measurement;
if (command.measurementId() == null) {
@@ -93,6 +94,7 @@ public class MeasurementBatchService {
return measurement;
}
/** 修改批次算法时,以实测及人工修正基数重算,避免重复叠加。 */
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems,
String coefficientMode, Long chargeRuleId) {
@@ -112,7 +114,7 @@ public class MeasurementBatchService {
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
List<InboundMeasurementItemEntity> recalculated = calculateItems(
command, dimensionWeighting.load(tenantId));
tenantId, command.items(), dimensionWeighting.load(tenantId));
for (int index = 0; index < existingItems.size(); index++) {
InboundMeasurementItemEntity target = existingItems.get(index);
InboundMeasurementItemEntity source = recalculated.get(index);
@@ -121,6 +123,7 @@ public class MeasurementBatchService {
applyTotals(measurement, existingItems);
}
/** 按保存的算法重算单行外箱修正,并更新批次汇总。 */
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems, long itemId,
BigDecimal outerLengthCm, BigDecimal outerWidthCm,
@@ -143,14 +146,42 @@ public class MeasurementBatchService {
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
target.getRemark())));
InboundMeasurementItemEntity recalculated = calculateItems(
command, dimensionWeighting.load(tenantId)).get(0);
tenantId, command.items(), dimensionWeighting.load(tenantId)).get(0);
applyRecalculatedItem(target, recalculated, true);
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,
InboundMeasurementItemEntity source,
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.setUnitVolumeCbm(source.getUnitVolumeCbm());
target.setVolumeRawCbm(source.getVolumeRawCbm());
@@ -163,15 +194,6 @@ public class MeasurementBatchService {
target.setOuterLengthCm(source.getOuterLengthCm());
target.setOuterWidthCm(source.getOuterWidthCm());
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());
}
/** 明确的行算法优先;未选择时仅使用客户默认算法,不因预置系数而自动启用。 */
private List<InboundMeasurementItemEntity> calculateItems(
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) {
long tenantId, List<MeasurementBatchCommand.Item> sourceItems,
CustomerDimensionWeightingService.RuleSet dimensionRules) {
Set<String> itemNumbers = new HashSet<>();
return command.items().stream().map(source -> {
return sourceItems.stream().map(source -> {
if (!itemNumbers.add(source.itemNo())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
}
@@ -279,10 +303,9 @@ public class MeasurementBatchService {
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
baseLength, baseWidth, baseHeight);
String requestedMode = normalizeMode(source.coefficientMode());
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode)
? requireCoefficientRule(command.tenantId(), source.chargeRuleId())
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId()) : null;
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
String coefficientMode = requestedMode == null ? dimensionRules.defaultCalculationMode() : requestedMode;
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(coefficientMode)
? requireCoefficientRule(tenantId, source.chargeRuleId()) : null;
boolean useDimensionRule = "ADD".equals(coefficientMode);
BigDecimal addition = weighted.edgeAdditionCm() == null
? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2));
@@ -374,10 +397,6 @@ public class MeasurementBatchService {
return normalized;
}
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId) {
return pricingRepository.findDefaultRule(tenantId, tenantId);
}
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) {
CustomerChargeRuleEntity rule = ruleId == null
? pricingRepository.findDefaultRule(tenantId, tenantId)
@@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
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 '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -234,6 +234,7 @@ components:
locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, 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}$'}
createdAt: {type: string, format: date-time}
ExceptionFact:
@@ -147,6 +147,13 @@ paths:
description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}}
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:
post:
summary: 按审核通过测量快照试算 FBA 费用
@@ -296,6 +303,32 @@ components:
updatedAt: {type: string, format: date-time, nullable: true}
deleted: {type: boolean, 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:
type: object
required: [code, message, data]
@@ -503,6 +536,15 @@ components:
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0}
unitPrice: {type: number, exclusiveMinimum: true, minimum: 0}
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:
type: object
properties:
@@ -2245,7 +2245,7 @@ components:
outerWidthCm: {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}
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}
InboundMeasurementDetailEnvelope:
type: object
@@ -7,6 +7,7 @@ paths:
/api/platform/v1/sailings/calendar:
get:
summary: 查询船期月历
description: 按 Asia/Shanghai 自然日仅返回今天及之后开船的船期和同范围汇总。有效已放舱订舱对应的排柜货量保留在其 OPEN/CLOSED 船期,截仓后仍保留;未安排或原船期取消、日期过期的未离仓余量,计入同仓、同渠道最近一班 OPEN 且尚未截仓的船期,按开船时间、船期编号和 ID 稳定排序。只调整展示投影,不改写订舱或排柜归属。响应 nextRefreshAt 为上海本地时间,取下一零点和同仓渠道范围内最近截仓时刻之后 1 毫秒的较早值,截仓查询不受展示月份、船名和港口筛选限制。
x-permission: platform:firstleg:sailing-calendar:list
parameters:
- {name: month, in: query, required: true, schema: {type: string, pattern: '^\\d{4}-\\d{2}$'}}
@@ -17,7 +18,7 @@ paths:
- {name: destinationPortCode, in: query, schema: {type: string, maxLength: 32}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: status, in: query, schema: {type: string}}
responses: {'200': {description: 月历和按船期汇总}}
responses: {'200': {description: 月历和按船期汇总;nextRefreshAt 为下次自动刷新时间,volumeSummary 和 summary 的 availableBookedCapacityCbm 为扣除已离仓柜后的可用订舱容量,各船期 volumeSummary 的 bookedPlanningCapacityCbm 保留订舱总容量。}}
/api/platform/v1/sailings:
post:
summary: 新增单次船期
@@ -58,6 +59,7 @@ paths:
/api/platform/v1/sailings/{id}/booking-advice:
get:
summary: 查询只读订柜建议
description: 建议柜数按当前展示的全部待发实测与可核验预计体积除以统一柜型容量向上取整,正货量至少一柜;待补柜先扣除未离仓柜的可用订舱容量后再向上取整,下限为零。已离仓柜按所属柜型需求整柜扣除容量,同柜多票只扣一次。日历、汇总和柜型方案使用相同口径。
x-permission: platform:firstleg:sailing-calendar:list
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/booking-links:
@@ -19,6 +19,7 @@ class FbaPricingContractTest {
@Test
@DisplayName("确认和发送费用兼容新的组合权限与原有权限")
void allowsCombinedConfirmSendPermissionAndLegacyPermissions() {
assertPricingAuthority("preview", "hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')");
assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:fba-pricing:confirm')");
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
@@ -58,6 +59,8 @@ class FbaPricingContractTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.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/carrier-rates/versions:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:")
@@ -8,6 +8,16 @@ import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
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
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
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;
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
void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception {
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);
}
/** 真实 Mapper 保存并返回操作用户名,账号更名后重放不改写原署名。 */
@Test
void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() {
var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
"ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles",
"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();
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body);
service.addContainerManualNode(3,10001,"renamed_operator","original-body-key",body);
assertThat(allSnapshot()).isEqualTo(saved);
assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key",
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
@@ -183,6 +183,7 @@ class CarrierScheduleQueryServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
}
/** 出仓节点记录确认人的账号,供两个货柜详情展示。 */
@Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
@@ -194,7 +195,7 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
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.getCarrierCode()).isEqualTo("COSCO");
@@ -204,23 +205,25 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(result.getCreatedBy()).isZero();
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM");
assertThat(result.getUpdatedBy()).isZero();
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM");
assertThat(result.getCreatedBy()).isEqualTo(7L);
assertThat(result.getCreatedByName()).isEqualTo("platform_admin");
assertThat(result.getUpdatedBy()).isEqualTo(7L);
assertThat(result.getUpdatedByName()).isEqualTo("platform_admin");
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox).publish(eq(result), argThat(fact ->
"FIRSTLEG".equals(fact.getProviderCode())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId())
&& "platform_admin".equals(fact.getOperatorName())
&& fact.getActualAt() != null), eq("ZCSU7881970"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null
&& Integer.valueOf(0).equals(history.getToVersion())
&& Long.valueOf(0L).equals(history.getOperatorId())
&& "SYSTEM".equals(history.getOperatorName())
&& Long.valueOf(7L).equals(history.getOperatorId())
&& "platform_admin".equals(history.getOperatorName())
&& "国内确认出仓自动创建".equals(history.getReason())));
}
@@ -360,6 +363,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
}
/** 自动采集推进原节点,但不冒用平台操作账号。 */
@Test
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0);
@@ -377,6 +381,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
assertThat(result.nodes()).isEmpty();
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
fact.getOperatorId() == null && fact.getOperatorName() == null));
}
@Test
@@ -448,6 +454,7 @@ class CarrierScheduleQueryServiceTest {
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
}
/** 人工实际节点保留操作账号及既有还柜幂等行为。 */
@Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3);
@@ -462,6 +469,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("STOPPED");
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());
}
@@ -488,6 +497,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository).updateState(before, 3, 7L, "admin");
}
/** 柜级补录沿用当前账号,并保持已有跟踪任务和事件投递。 */
@Test
void containerManualNodeJoinsAndAdvancesExistingCarrierTask() {
CarrierScheduleQueryEntity task = task(0, 8);
@@ -509,7 +519,8 @@ class CarrierScheduleQueryServiceTest {
"PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup"));
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).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"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();
}
}
@@ -0,0 +1,388 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
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.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class SailingCalendarDisplayMapperTest {
private JdbcTemplate jdbc;
private SqlSession session;
private SailingCalendarDisplayMapper mapper;
/** 使用隔离内存库执行实际 Mapper,覆盖顺延、离仓扣减和去重规则。 */
@BeforeEach
void setUp() {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE fl_sailing(id BIGINT, sailing_no VARCHAR, warehouse_id BIGINT, channel_code_snapshot VARCHAR, status VARCHAR, etd_at TIMESTAMP, warehouse_cutoff_at TIMESTAMP, departure_port_code VARCHAR, destination_port_code VARCHAR, vessel_name VARCHAR, voyage_no VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT, tenant_id BIGINT, deleted INT, measure_review_status VARCHAR, process_status VARCHAR, warehouse_id BIGINT, channel_code VARCHAR, split_status VARCHAR, actual_volume_cbm DECIMAL(18,6), inbounded_at TIMESTAMP, measure_completed_at TIMESTAMP, created_at TIMESTAMP, total_package_count INT)");
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT, container_booking_id BIGINT)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT, loading_plan_id BIGINT, account_id BIGINT, inbound_id BIGINT, deleted INT, volume_cbm DECIMAL(18,6), inbound_detail_id BIGINT DEFAULT 1, package_count INT DEFAULT 0, unit_type VARCHAR DEFAULT 'PACKAGE')");
jdbc.execute("CREATE TABLE fl_inbound_measurement(id BIGINT, tenant_id BIGINT, inbound_id BIGINT, status VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_measurement_item(id BIGINT, tenant_id BIGINT, measurement_id BIGINT, inbound_detail_id BIGINT, package_count INT, total_volume_cbm DECIMAL(18,4))");
jdbc.execute("CREATE TABLE fl_outbound_dispatch(tenant_id BIGINT, inbound_id BIGINT, container_booking_id BIGINT, status VARCHAR, confirmed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_ocean_booking(id BIGINT,status VARCHAR,warehouse_id BIGINT,channel_code VARCHAR,departure_port_code VARCHAR,destination_port_code VARCHAR,vessel_name VARCHAR,voyage_no VARCHAR,estimated_sailing_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_ocean_booking_container_requirement(id BIGINT,ocean_booking_id BIGINT,quantity INT,capacity_cbm DECIMAL(18,6))");
jdbc.execute("CREATE TABLE fl_ocean_booking_container_allocation(ocean_booking_id BIGINT,requirement_id BIGINT,container_booking_id BIGINT)");
jdbc.execute("CREATE TABLE fl_sailing_booking_link(sailing_id BIGINT,ocean_booking_id BIGINT,link_status VARCHAR)");
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), source));
configuration.addMapper(SailingCalendarDisplayMapper.class);
session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession();
mapper = session.getMapper(SailingCalendarDisplayMapper.class);
sailing(1, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
sailing(2, "2026-11-04T10:00:00", "2026-11-03T18:00:00", "OPEN");
}
/** 释放本测试的会话和内存表。 */
@AfterEach
void tearDown() {
session.close();
jdbc.execute("DROP ALL OBJECTS");
}
/** 测量前全票占位无论高估、低估或箱数调整,都只计入审核后的实测货量。 */
@ParameterizedTest
@CsvSource({"100,6000", "40,6000", "100,10000"})
void recomputesDeclaredAllocationFromApprovedMeasurement(String declaredVolume, int allocatedBoxes) {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "60");
jdbc.update("UPDATE fl_loading_plan_item SET volume_cbm=?,package_count=?", new java.math.BigDecimal(declaredVolume), allocatedBoxes);
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
assertThat(advice(2, "2026-10-27T12:00:00").recommendedContainerCount()).isEqualTo(1);
assertThat(advice(2, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
}
/** 不同规格按自己的实测单箱体积计算,避免用整票均值错误分配部分货量。 */
@Test
void computesPartialAllocationsUsingEachMeasuredSpecification() {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET total_package_count=3000 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET package_count=1000,total_volume_cbm=10 WHERE id=10");
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(11,7,10,2,2000,50)");
allocate(1, 100, "5");
allocate(1, 200, "5");
jdbc.update("UPDATE fl_loading_plan_item SET inbound_detail_id=2 WHERE id=200");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("42.5");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("17.5");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,200,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("5");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("42.5");
}
/** 三等分等循环小数的分摊总量仍等于实测,不产生额外余量补柜建议。 */
@Test
void conservesMeasuredVolumeAcrossRoundedAllocations() {
booking(2, 3, "68");
inbound(1, "1", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET total_package_count=3 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET package_count=3 WHERE id=10");
allocate(1, 100, "0.01");
allocate(1, 200, "0.01");
allocate(1, 300, "0.01");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("1");
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("0.6667");
}
/** 只读取最新已审核批次,同明细多箱规累加,排除待审核批次及其他客户。 */
@Test
void usesOnlyTheLatestApprovedMeasurementForTheAllocationOwner() {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "60");
jdbc.update("UPDATE fl_inbound_measurement_item SET total_volume_cbm=100 WHERE id=10");
jdbc.update("INSERT INTO fl_inbound_measurement VALUES(20,7,1,'APPROVED'),(30,7,1,'SUBMITTED'),(40,8,1,'APPROVED')");
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(20,7,20,1,2000,10),(21,7,20,1,4000,50),(30,7,30,1,6000,999),(40,8,40,1,6000,999)");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
}
/** 一件代发整托只使用主行的物理托盘体积,附加 SKU 行不能再次增加货量。 */
@Test
void keepsSealedPalletPhysicalVolumeWithoutDuplicatingSkuLines() {
booking(2, 1, "68");
inbound(1, "20", "LOADING", "NOT_SPLIT");
allocate(1, 100, "20");
jdbc.update("UPDATE fl_loading_plan_item SET unit_type='SEALED_PALLET'");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,inbound_detail_id,package_count,unit_type) VALUES(200,100,7,1,0,0,2,2000,'SEALED_PALLET')");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("20");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
/** 老货不按原收货窗口丢失,截止前只计入最近一班,截止后跨月顺延。 */
@Test
void rollsUnshippedBacklogForwardOnlyOnceAndAcrossMonths() {
inbound(1, "113.74", "WAIT_SPLIT", "NOT_SPLIT");
assertThat(summary(1, "2026-10-27T18:00:00").catchableCbm()).isEqualByComparingTo("113.74");
assertThat(summary(2, "2026-10-27T18:00:00").catchableCbm()).isZero();
assertThat(summary(1, "2026-10-27T18:00:01").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T18:00:01").catchableCbm()).isEqualByComparingTo("113.74");
assertThat(summary(2, "2026-11-02T12:00:00").catchableCbm()).isEqualByComparingTo("113.74");
}
/** 取消、截止和过期航次不承接货量;同一时间的两班也只选择一班。 */
@Test
void skipsIneligibleSailingsAndBreaksTiesDeterministically() {
inbound(1, "12", "WAIT_SPLIT", "NOT_SPLIT");
sailing(3, "2026-10-26T10:00:00", "2026-10-29T18:00:00", "OPEN");
sailing(4, "2026-10-27T15:00:00", "2026-10-27T14:00:00", "CANCELLED");
sailing(5, "2026-10-27T16:00:00", "2026-10-27T14:00:00", "CLOSED");
sailing(6, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("12");
for (long id : new long[]{2, 3, 4, 5, 6}) {
assertThat(summary(id, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
}
/** 实际离仓才扣减,部分离仓只扣该柜明细,撤销离仓后重新纳入。 */
@Test
void subtractsOnlyConfirmedDeparturesAndRestoresReversedVolume() {
inbound(1, "100", "LOADED", "NOT_SPLIT");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,100),(20,200)");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(1,10,7,1,0,40,4000),(2,20,7,1,0,60,6000),(3,10,8,1,0,999,99900),(4,10,7,1,1,999,99900)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00'),(7,1,200,'READY_FOR_DISPATCH',NULL),(8,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00')");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
jdbc.update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH' WHERE tenant_id=7 AND container_booking_id=100");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("UPDATE fl_outbound_dispatch SET status='WAITING_FOR_LOADING',confirmed_at='2026-10-27 11:00:00' WHERE tenant_id=7");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
/** 拣货结算后的源单和余量已各自保存实测体积,保留未离仓部分并排除失效数据。 */
@Test
void includesLoadedSplitSourceAndRemainderWithoutCountingInvalidReceipts() {
inbound(1, "40", "LOADED", "FULLY_SPLIT");
inbound(2, "60", "WAIT_SPLIT", "NOT_SPLIT");
inbound(3, "100", "WAIT_SPLIT", "PARTIALLY_SPLIT");
inbound(4, "999", "DELIVERED", "NOT_SPLIT");
inbound(5, "999", "CANCELLED", "NOT_SPLIT");
inbound(6, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(7, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(8, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(9, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(10, "999", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET warehouse_id=99 WHERE id=6");
jdbc.update("UPDATE fl_inbound_order SET channel_code='OTHER' WHERE id=7");
jdbc.update("UPDATE fl_inbound_order SET deleted=1 WHERE id=8");
jdbc.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=9");
jdbc.update("UPDATE fl_inbound_order SET inbounded_at='2026-10-28 10:00:00' WHERE id=10");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,100)");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(1,10,7,1,0,40,4000)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00')");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
}
/** 无可赶上的船期时不把货量展示在已截止航次,新建有效船期后可再次承接。 */
@Test
void waitsForANewEligibleSailingWithoutLosingTheBacklog() {
inbound(1, "10", "WAIT_SPLIT", "NOT_SPLIT");
assertThat(summary(2, "2026-11-04T12:00:00").catchableCbm()).isZero();
sailing(3, "2026-11-11T10:00:00", "2026-11-10T18:00:00", "OPEN");
assertThat(summary(3, "2026-11-04T12:00:00").catchableCbm()).isEqualByComparingTo("10");
}
/** 已离仓柜的总需求仍可查看,但其容量不能继续抵扣仓内余货。 */
@Test
void excludesDepartedContainerCapacityFromRemainingCargoAdvice() {
booking(1, 1, "68");
inbound(1, "68", "LOADED", "NOT_SPLIT");
inbound(2, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
var booked = mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00"));
assertThat(booked.containerCount()).isEqualTo(1);
assertThat(booked.capacityCbm()).isEqualByComparingTo("68");
assertThat(booked.availableCapacityCbm()).isZero();
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isEqualTo(1);
jdbc.update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH'");
session.clearCache();
assertThat(mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00")).availableCapacityCbm()).isEqualByComparingTo("68");
}
/** 同柜多票只扣一次柜容量,多个柜型需求按各自容量独立扣减。 */
@Test
void subtractsEachDepartedContainerOnceFromItsOwnRequirement() {
booking(1, 2, "68");
jdbc.update("INSERT INTO fl_ocean_booking_container_requirement VALUES(1002,1000,1,58)");
jdbc.update("INSERT INTO fl_ocean_booking_container_allocation VALUES(1000,1001,100),(1000,1001,200),(1000,1002,300)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00'),(8,2,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00'),(7,3,200,'READY_FOR_DISPATCH',NULL),(7,4,300,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
var booked = mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00"));
assertThat(booked.containerCount()).isEqualTo(3);
assertThat(booked.capacityCbm()).isEqualByComparingTo("194");
assertThat(booked.availableCapacityCbm()).isEqualByComparingTo("68");
}
/** 已确认后班的货保留在后班,只有未安排余量进入前班,避免重复订柜。 */
@Test
void preservesConfirmedSailingAndOnlyRollsTheUnallocatedRemainder() {
booking(2, 1, "68");
inbound(1, "100", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("32");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isEqualTo(1);
assertThat(advice(2, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
// 全票已排柜时,前班不再产生任何补柜建议。
jdbc.update("UPDATE fl_inbound_order SET actual_volume_cbm=68,total_package_count=6800 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET total_volume_cbm=68,package_count=6800 WHERE measurement_id=10");
session.clearCache();
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
jdbc.update("UPDATE fl_sailing SET status='CLOSED' WHERE id=2");
session.clearCache();
assertThat(summary(2, "2026-11-03T19:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 原安排取消后只顺延未离仓部分;已有实际离仓事实不会再次进入待发池。 */
@Test
void rollsInvalidatedBookingsWithoutRestoringDepartedCargo() {
booking(2, 1, "68");
inbound(1, "100", "LOADED", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_sailing SET status='CANCELLED' WHERE id=2");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("32");
}
/** 显式关联优先于运输快照匹配,未关联时同名船期也只确定一班。 */
@Test
void resolvesOneBookingTargetAndHonorsAnExplicitLink() {
booking(2, 1, "68");
inbound(1, "68", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_ocean_booking SET vessel_name='VESSEL',voyage_no='VOYAGE-1',estimated_sailing_at='2026-10-28 10:00:00'");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
jdbc.update("UPDATE fl_sailing_booking_link SET link_status='REVOKED'");
sailing(3, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
jdbc.update("UPDATE fl_sailing SET voyage_no='VOYAGE-1' WHERE id=3");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
assertThat(summary(3, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(mapper.bookingSummary(3, LocalDateTime.parse("2026-10-27T00:00:00")).containerCount()).isZero();
}
/** 跨表文本转换必须保留完整编码与中文船名,不能将同前缀的运输信息视为同一船期。 */
@ParameterizedTest
@CsvSource({"channel_code,MATSON-OTHER", "departure_port_code,CNSHA", "destination_port_code,USLAX",
"vessel_name,远洋轮乙", "voyage_no,VOYAGE-OTHER"})
void matchesCompleteTransportSnapshotsAfterTextConversion(String column, String differentValue) {
booking(1, 1, "68");
jdbc.update("UPDATE fl_sailing_booking_link SET link_status='REVOKED'");
jdbc.update("UPDATE fl_sailing SET vessel_name='远洋轮甲' WHERE id=1");
jdbc.update("UPDATE fl_ocean_booking SET vessel_name='远洋轮甲',voyage_no='VOYAGE-1',estimated_sailing_at='2026-10-28 10:00:00'");
var today = LocalDateTime.parse("2026-10-27T00:00:00");
assertThat(mapper.bookingSummary(1, today).containerCount()).isEqualTo(1);
jdbc.update("UPDATE fl_ocean_booking SET " + column + "=?", differentValue);
session.clearCache();
assertThat(mapper.bookingSummary(1, today).containerCount()).isZero();
}
/** 顺延按完整渠道编码匹配,排除只有前缀相同的更早船期。 */
@Test
void rollsCargoOnlyToTheCompleteChannelCode() {
inbound(1, "68", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_sailing SET channel_code_snapshot='MATSON-OTHER' WHERE id=1");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 人工跨仓、跨渠道排柜已有正式订舱时,应跟随真实安排展示。 */
@Test
void preservesCrossWarehouseAndChannelAssignments() {
booking(2, 1, "68");
inbound(1, "68", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_inbound_order SET warehouse_id=99,channel_code='OTHER'");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 查看后月或筛选后班时仍返回前班截仓节点,仓库和渠道范围继续生效。 */
@Test
void findsRefreshBoundaryOutsideTheDisplayedMonthAndKeyword() {
var query = new com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery("2026-11", 8L, null, "MATSON", "OTHER", "OTHER", "SL-2", "CLOSED");
var now = LocalDateTime.parse("2026-10-27T12:00:00");
assertThat(mapper.nextCutoff(query, now, now.toLocalDate().atStartOfDay())).isEqualTo("2026-10-27T18:00:00");
assertThat(mapper.nextCutoff(query, LocalDateTime.parse("2026-10-27T18:00:01"), now.toLocalDate().atStartOfDay())).isEqualTo("2026-11-03T18:00:00");
var otherWarehouse = new com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery("2026-11", 99L, null, "MATSON", null, null, null, null);
assertThat(mapper.nextCutoff(otherWarehouse, now, now.toLocalDate().atStartOfDay())).isNull();
}
/** 建立一条已放舱订舱及唯一显式船期关联。 */
private void booking(long sailingId, int quantity, String capacity) {
jdbc.update("INSERT INTO fl_ocean_booking VALUES(1000,'RELEASED',8,'MATSON','CNNGB','USLGB',NULL,NULL,NULL)");
jdbc.update("INSERT INTO fl_ocean_booking_container_requirement VALUES(1001,1000,?,?)", quantity, new java.math.BigDecimal(capacity));
jdbc.update("INSERT INTO fl_sailing_booking_link VALUES(?,1000,'ACTIVE')", sailingId);
}
/** 按真实柜次关联订舱需求,并记录对应入库体积。 */
private void allocate(long inboundId, long containerId, String volume) {
jdbc.update("INSERT INTO fl_loading_plan VALUES(?,?)", containerId, containerId);
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(?,?,7,?,0,?,?)", containerId, containerId, inboundId, new java.math.BigDecimal(volume), new java.math.BigDecimal(volume).movePointRight(2).intValueExact());
jdbc.update("INSERT INTO fl_ocean_booking_container_allocation VALUES(1000,1001,?)", containerId);
}
/** 将实际 Mapper 的货量与容量投影送入生产订柜算法,验证组合场景。 */
private com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.BookingAdvice advice(long id, String time) {
var now = LocalDateTime.parse(time);
var repository = org.mockito.Mockito.mock(com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository.class);
var sailing = new com.baiyue.firstleg.sailingcalendar.entity.SailingEntity();
sailing.setId(id);
org.mockito.Mockito.when(repository.sailing(id)).thenReturn(sailing);
org.mockito.Mockito.when(repository.receiptSummary(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(summary(id, time));
org.mockito.Mockito.when(repository.bookingSummary(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(mapper.bookingSummary(id, now.toLocalDate().atStartOfDay()));
var service = new com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "repository", repository);
return service.bookingAdvice(id);
}
/** 构造同仓同渠道的船期,只使用公开业务编号排序。 */
private void sailing(long id, String etd, String cutoff, String status) {
jdbc.update("INSERT INTO fl_sailing VALUES(?, ?, 8, 'MATSON', ?, ?, ?, 'CNNGB', 'USLGB', 'VESSEL', ?)", id, "SL-" + id, status,
LocalDateTime.parse(etd), LocalDateTime.parse(cutoff), "VOYAGE-" + id);
}
/** 构造上月遗留的审核实测货量。 */
private void inbound(long id, String volume, String status, String splitStatus) {
var measuredVolume = new java.math.BigDecimal(volume);
int boxes = measuredVolume.movePointRight(2).intValueExact();
jdbc.update("INSERT INTO fl_inbound_order VALUES(?,7,0,'APPROVED',?,8,'MATSON',?,?,'2026-09-01 10:00:00',NULL,NULL,?)",
id, status, splitStatus, measuredVolume, boxes);
jdbc.update("INSERT INTO fl_inbound_measurement VALUES(?,7,?,'APPROVED')", id * 10, id);
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(?,7,?,1,?,?)", id * 10, id * 10, boxes, measuredVolume);
}
/** 用上海业务时间调用生产 SQL,读取目标船期自己的截止时间。 */
private SailingCalendarDisplayMapper.ReceiptSummary summary(long id, String time) {
LocalDateTime now = LocalDateTime.parse(time);
LocalDateTime cutoff = jdbc.queryForObject("SELECT warehouse_cutoff_at FROM fl_sailing WHERE id=?", LocalDateTime.class, id);
return mapper.receiptSummary(id, 8L, "MATSON", cutoff, now, now.toLocalDate().atStartOfDay());
}
}
@@ -2,16 +2,24 @@ package com.baiyue.firstleg.sailingcalendar.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.CopyRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
@@ -22,6 +30,8 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.mock;
class SailingCalendarServiceTest {
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
@@ -31,10 +41,13 @@ class SailingCalendarServiceTest {
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
@BeforeEach
/** 固定上海跨日边界,避免测试依赖运行机日期和时区。 */
void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "clock", Clock.fixed(Instant.parse("2026-10-26T16:00:00Z"), ZoneId.of("UTC")));
}
/** 复制幂等回放保留原有船期,只重新读取当前展示汇总。 */
@Test
void replaysAnExistingCopyInsteadOfReturningAnInternalError() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
@@ -45,8 +58,8 @@ class SailingCalendarServiceTest {
when(repository.sailing(2L)).thenReturn(existing);
when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing);
when(repository.histories(2L)).thenReturn(List.of(history));
when(repository.receiptSummary(any(SailingEntity.class))).thenReturn(null);
when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null);
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenReturn(null);
when(repository.bookingSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenReturn(null);
var result = service.copy(1L, 7L, "admin",
new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3)));
@@ -81,6 +94,117 @@ class SailingCalendarServiceTest {
verify(repository, never()).insert(any(SailingEntity.class));
}
/** 各柜型独立向上取整,正数不足一柜也应给出一柜建议。 */
@ParameterizedTest
@CsvSource({"0,0,0,0", "0.001,1,1,1", "58,1,1,1", "58.001,2,1,1", "68,2,1,1",
"68.001,2,2,1", "86,2,2,1", "86.001,2,2,2", "113.74,2,2,2"})
void roundsAllReceivedVolumeUpForEachContainerType(String volume, int gp, int hq, int largeHq) {
SailingEntity sailing = sailing(1L, "SL-ADVICE", "MANUAL", "TEST", copiedDeparture);
when(repository.sailing(1L)).thenReturn(sailing);
// 复现截图:全部实收都被标为晚到,不能因此得到零柜。
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(BigDecimal.ZERO, new BigDecimal(volume), 1));
var advice = service.bookingAdvice(1L);
var summary = service.volumeSummary(1L).summary();
assertThat(advice.actualReceivedCbm()).isEqualByComparingTo(volume);
assertThat(advice.catchableTotalCbm()).isZero();
assertThat(advice.lateReceivedCbm()).isEqualByComparingTo(volume);
assertThat(advice.recommendedContainerCount()).isEqualTo(hq);
assertThat(advice.pendingContainerCount()).isEqualTo(hq);
assertThat(summary.recommendedContainerCount()).isEqualTo(hq);
assertThat(summary.pendingContainerCount()).isEqualTo(hq);
assertThat(advice.scenarios()).extracting(s -> s.recommendedContainerCount()).containsExactly(gp, hq, largeHq);
assertThat(advice.scenarios()).extracting(s -> s.pendingContainerCount()).containsExactly(gp, hq, largeHq);
}
/** 待补柜扣除已订实际容量,而不是简单扣除已订柜数,超额容量不产生负数。 */
@ParameterizedTest
@CsvSource({"1,58,2,2,1", "2,126,1,1,1", "2,136,0,0,0", "3,204,0,0,0"})
void subtractsBookedCapacityBeforeRoundingTheShortage(int booked, String capacity, int gp, int hq, int largeHq) {
when(repository.sailing(1L)).thenReturn(sailing(1L, "SL-BOOKED", "MANUAL", "TEST", copiedDeparture));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(new BigDecimal("100"), new BigDecimal("36"), 1));
when(repository.bookingSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.BookingSummary(booked, new BigDecimal(capacity), new BigDecimal(capacity)));
var advice = service.bookingAdvice(1L);
assertThat(advice.recommendedContainerCount()).isEqualTo(2);
assertThat(advice.bookedContainerCount()).isEqualTo(booked);
assertThat(advice.pendingContainerCount()).isEqualTo(hq);
assertThat(service.volumeSummary(1L).summary().pendingContainerCount()).isEqualTo(hq);
assertThat(advice.scenarios()).extracting(s -> s.pendingContainerCount()).containsExactly(gp, hq, largeHq);
}
/** 上海今日零点前过期,今日整天及未来月份仍可参与列表和汇总。 */
@ParameterizedTest
@CsvSource({"2026-10,2026-10-27,2026-11-01", "2026-11,2026-11-01,2026-12-01"})
void limitsCalendarAndSummaryToTheUpcomingWindow(String month, LocalDate from, LocalDate to) {
SailingQuery query = new SailingQuery(month, null, null, null, null, null, null, null);
SailingEntity first = sailing(1L, "SL-TODAY", "MANUAL", "TEST", from.atStartOfDay());
when(repository.calendar(query, from.atStartOfDay(), to.atStartOfDay())).thenReturn(List.of(first));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(new BigDecimal("113.74"), BigDecimal.ZERO, 0));
var result = service.calendar(query);
verify(repository).calendar(query, from.atStartOfDay(), to.atStartOfDay());
assertThat(result.records()).extracting(r -> r.etdAt()).containsExactly(from.atStartOfDay());
assertThat(result.summary().sailingCount()).isEqualTo(1);
assertThat(result.summary().actualReceivedCbm()).isEqualByComparingTo("113.74");
assertThat(result.summary().recommendedContainerCount()).isEqualTo(2);
}
/** 过去月份不查询历史货量,也不把历史船期混入汇总。 */
@Test
void returnsAnEmptyCalendarForAnExpiredMonth() {
var result = service.calendar(new SailingQuery("2026-09", null, null, null, null, null, null, null));
assertThat(result.records()).isEmpty();
assertThat(result.summary().sailingCount()).isZero();
assertThat(result.summary().recommendedContainerCount()).isZero();
verifyNoInteractions(repository);
}
/** 刷新时间包括隐藏月份的前班截仓节点,并以上海零点为最长等待边界。 */
@ParameterizedTest
@CsvSource({"2026-10-27T00:00:00,2026-10-27T00:00:00.001", "2026-10-27T18:00:00,2026-10-27T18:00:00.001", "2026-11-03T18:00:00,2026-10-28T00:00:00"})
void schedulesRefreshUsingCrossMonthCutoff(String cutoff, String expectedRefresh) {
SailingQuery query = new SailingQuery("2026-11", null, null, null, null, null, "后班", null);
when(repository.nextCutoff(query, LocalDateTime.parse("2026-10-27T00:00:00")))
.thenReturn(LocalDateTime.parse(cutoff));
assertThat(service.calendar(query).nextRefreshAt()).isEqualTo(LocalDateTime.parse(expectedRefresh));
}
/** 即使系统时钟跨过截仓瞬间,同一次月历的所有船期也必须共享同一投影时刻。 */
@Test
void sharesOneSnapshotTimeAcrossCalendarRows() {
Clock movingClock = mock(Clock.class);
when(movingClock.withZone(any(ZoneId.class))).thenReturn(movingClock);
when(movingClock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai"));
when(movingClock.instant()).thenReturn(Instant.parse("2026-10-27T10:00:00Z"), Instant.parse("2026-10-27T10:00:01Z"));
ReflectionTestUtils.setField(service, "clock", movingClock);
SailingQuery query = new SailingQuery("2026-10", null, null, null, null, null, null, null);
SailingEntity first = sailing(1L, "SL-FIRST", "MANUAL", "TEST-1", LocalDateTime.parse("2026-10-28T10:00:00"));
SailingEntity second = sailing(2L, "SL-SECOND", "MANUAL", "TEST-2", LocalDateTime.parse("2026-10-30T10:00:00"));
LocalDateTime cutoff = LocalDateTime.parse("2026-10-27T18:00:00");
when(repository.calendar(any(SailingQuery.class), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(first, second));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenAnswer(call -> {
SailingEntity sailing = call.getArgument(0);
LocalDateTime asOf = call.getArgument(1);
boolean counted = asOf.isAfter(cutoff) ? sailing.getId() == 2L : sailing.getId() == 1L;
return new SailingCalendarDisplayMapper.ReceiptSummary(counted ? BigDecimal.TEN : BigDecimal.ZERO, BigDecimal.ZERO, 0);
});
var result = service.calendar(query);
assertThat(result.summary().actualReceivedCbm()).isEqualByComparingTo("10");
assertThat(result.records()).extracting(row -> row.volumeSummary().snapshotAt()).containsExactly(cutoff, cutoff);
verify(movingClock).instant();
}
/** 构造具备稳定业务编号的船期样本。 */
private static SailingEntity sailing(long id, String sailingNo, String sourceType, String voyageNo, LocalDateTime etdAt) {
SailingEntity value = new SailingEntity();
value.setId(id); value.setSailingNo(sailingNo); value.setSourceType(sourceType); value.setVoyageNo(voyageNo);
@@ -7,12 +7,14 @@ import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -46,13 +48,18 @@ class CartonMeasurementServiceTest {
private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class);
private final CustomerDimensionWeightingService dimensionWeighting =
new CustomerDimensionWeightingService(dimensionConfigs);
private final MeasurementBatchService measurementBatchService = new MeasurementBatchService();
private final FbaPricingRepository pricingRepository = mock(FbaPricingRepository.class);
private CartonMeasurementService service;
private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>();
@BeforeEach
/** 构造真实共享尺寸计算入口,设备回归不以模拟计算结果代替业务算法。 */
void setUp() {
ReflectionTestUtils.setField(measurementBatchService, "dimensionWeighting", dimensionWeighting);
ReflectionTestUtils.setField(measurementBatchService, "pricingRepository", pricingRepository);
service = new CartonMeasurementService(repository, resolverProvider, images, transactions,
dimensionWeighting);
measurementBatchService);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
when(resolverProvider.getIfAvailable()).thenReturn(resolver);
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
@@ -164,7 +171,9 @@ class CartonMeasurementServiceTest {
}
@Test
/** 超出四档尺寸且未启用调整规则时,仍按设备原尺寸计算。 */
void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesActualDimensions() {
useDefaultMode("ACTUAL");
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
@@ -187,7 +196,7 @@ class CartonMeasurementServiceTest {
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("60.100");
assertThat(insertedItem.get().getOuterWidthCm()).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();
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);
}
@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() {
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"),
new BigDecimal("40.200"), new BigDecimal("30.300"),
@@ -388,6 +452,7 @@ class CartonMeasurementServiceTest {
return item;
}
/** 既有加边用例显式启用 ADD,避免依赖配置存在即生效的旧行为。 */
private List<CustomerDimensionConfigEntity> dimensionRules(long tenantId) {
return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> {
CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity();
@@ -395,6 +460,7 @@ class CartonMeasurementServiceTest {
rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm());
rule.setInnerHeightCm(type.defaultHeightCm());
rule.setEdgeAdditionCm(new BigDecimal("2.000"));
rule.setDefaultCalculationMode("ADD");
return rule;
}).toList();
}
@@ -39,15 +39,9 @@ class CustomerDimensionWeightingServiceTest {
}
@Test
void deviceMeasurementsUseActualDimensionsAndAddEachSideWhenRuleMatches() {
var weighted = service.load(10001L).resolveDeviceMeasurement(
new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10"));
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");
/** 未勾选默认调整算法时,四档尺寸配置不能隐式启用加边。 */
void defaultCalculationModeRemainsActualWhenNoAdjustmentWasSelected() {
assertThat(service.load(10001L).defaultCalculationMode()).isEqualTo("ACTUAL");
}
@Test
@@ -81,16 +75,15 @@ class CustomerDimensionWeightingServiceTest {
}
@Test
void deviceDimensionsOutsideConfiguredRulesRemainUnchanged() {
var weighted = service.load(10001L).resolveDeviceMeasurement(
new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50"));
/** 默认算法必须在四档配置中一致,防止各录入入口取得不同计算方式。 */
void inconsistentCustomerDefaultModesAreRejected() {
List<CustomerDimensionConfigEntity> rules = defaultRules(10001L);
rules.get(0).setDefaultCalculationMode("MULTIPLY");
when(repository.findAll(10001L)).thenReturn(rules);
assertThat(weighted.boxType()).isNull();
assertThat(weighted.edgeAdditionCm()).isNull();
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("60.001");
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("50.000");
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("50.000");
assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
assertThatThrownBy(() -> service.load(10001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("默认箱规计算方式配置不一致");
}
@Test
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
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.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
@@ -70,6 +71,87 @@ class FbaPricingServiceTest {
@MockBean private ContainerTierAmountAllocator containerTierAllocator;
@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
@DisplayName("客户B不能读取客户A的费用快照")
void rejectsCrossCustomerFeeLookup() {
@@ -52,6 +52,7 @@ class MeasurementBatchServiceTest {
}
@Test
/** 未选择调整算法时,第三方与人工批次均保存实际外箱快照。 */
void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() {
MeasurementBatchCommand command = new MeasurementBatchCommand(
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).getCalculationRuleVersion())
.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).getGirthCm()).isEqualByComparingTo("145.000");
verify(pricingRepository).findDefaultRule(1L, 1L);
verify(pricingRepository, never()).findDefaultRule(1L, 1L);
verify(repository, never()).updateInbound(any());
}
@@ -173,8 +174,78 @@ class MeasurementBatchServiceTest {
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
}
/** 全部清空人工修正后,计算与批次汇总须恢复设备实测尺寸。 */
@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() {
useDefaultMode("MULTIPLY");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(77L);
rule.setTenantId(1L);
@@ -212,7 +283,9 @@ class MeasurementBatchServiceTest {
}
@Test
/** 系数模式按当前尺寸匹配档位,同一系数作用于三个维度。 */
void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() {
useDefaultMode("MULTIPLY");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(78L);
rule.setTenantId(1L);
@@ -251,6 +324,7 @@ class MeasurementBatchServiceTest {
}
@Test
/** 无默认系数且未选择调整算法时,显式保存 ACTUAL 计算方式。 */
void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
@@ -269,7 +343,7 @@ class MeasurementBatchServiceTest {
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000");
assertThat(calculated.getChargeRuleId()).isNull();
assertThat(calculated.getCoefficientMode()).isNull();
assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
}
@@ -530,6 +604,58 @@ class MeasurementBatchServiceTest {
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) {
return new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
@@ -30,8 +30,8 @@ DROP PROCEDURE check_overseas_fee_permission_identity;
START TRANSACTION;
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)
VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=0,status='ACTIVE',deleted=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=5105000,sort_no=60,status='ACTIVE',deleted=0;
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
+1 -1
View File
@@ -79,7 +79,7 @@
| 外接询价、确认占位及取消 | `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,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
| 车次货物结案、车辆和司机分别释放 | `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` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
+9 -4
View File
@@ -36,6 +36,7 @@
| 托盘分配算法 | `inboundplan/service/PalletAllocator.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 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/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/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` |
@@ -87,7 +88,7 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五类装车照片(每类最多5张),无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -99,7 +100,7 @@
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。全车正常派送完成自动结案,结案不改客户状态/费用;结案与车/司机释放占用同事务,提货全部到仓、本土卡车完成也直接释放,不需二次确认。自动释放记录运输完成时刻及路线终点,保留车辆主档状态。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `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/`。
@@ -135,7 +136,7 @@
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。
- 海外采购按 `procurement-workflow.md` 的七状态流程执行:创建待审核,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。
- 派车状态只允许 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`。
@@ -163,8 +164,12 @@
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
- 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。
### 海外人工收费首版切换
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购保留独立收费流程,禁止进入人工通用收费。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
- 废弃PDA:无箱唛关联的普通库存先扫本单托盘,再扫描现场箱唛自动累计各明细实际箱数,禁止手填数量绕过扫码。现场箱唛独立保存到 actual_carton_marks_json,不改客户选箱快照;提交校验数量一致、同托及同单跨托盘不重复,失败不落盘、可刷新重扫。客户/平台详情返回 actualCartonMarks、palletNo、实际箱数和原备注。数量申请达到目标后释放未用预留。
@@ -264,3 +264,7 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。
### 档案照片只读复用边界(2026-10-26)
`DispatchMasterPhotoService`仅通过`fleet.photo.port.FleetResourcePhotoPort`及其照片DTO读取档案照片,再经排车文件网关生成当趟副本。`TransportTaskService`仅新增照片类别DTO引用,不读取照片Mapper或Repository。`fleet.photo`复用车辆`VehicleRepository`应用端口与司机应用服务校验身份,不反向依赖排车;精确边界纳入依赖合同。
@@ -38,10 +38,27 @@
- 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。
- 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。
- 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。
- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql) 仅解决自动仓租双零金额和空汇率入费用源表,保留人工正额与正常费用有效汇率约束,不更新业务数据。独立的 [既有费用写权限修复](../../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 写权限,不能代替结构升级或菜单初始化。
- 新空库:Lastleg 源基线与海外统一初始化脚本同步新表和字段;国内统一基线同步菜单。菜单查询权限 `platform:overseas-fee:list`,编辑与审核分别沿用 `platform:finance:fee-source:edit/review`,仅有查询权限不能写费用。
- 配置:核对 Firstleg、Warehouse 内部服务地址及内部令牌、客户仓租阶梯、有效 USD/CNY 月度汇率、Finance 资金与费用事件直投及回执配置。当前结算支持 USD、CNY,CNY 按1:1结算,其他币种必须先调整为受支持币种。不得在材料中记录密钥。
- 发布顺序:先发布 Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。
- 发布顺序:先发布 Firstleg 公共费用目录、Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。
### TEST 配套发布清单
本清单用于准备和审阅,不表示环境已完成执行。实际版本、部署结果及未关闭验收项仍只维护于[测试环境主契约](../../../docs/测试环境单机部署与配置契约.md)。发布前冻结后端、Admin、海外 PDA 的精确提交和产物摘要;执行期间任一版本变化都需重新核对受影响项,不使用浮动分支头替代审阅版本。
| 顺序 | 对象 | 执行前提与通过标准 |
|---|---|---|
| 1 | 规则与执行窗口 | 确认人工应收是否开放“推送结算/余额扣费”选择:当前前后端已支持显式选择,未选择才按业务默认规则;本文件首版固定分流描述不能作为该选择功能已获确认的依据。明确本批 TEST SQL、服务发布及测试客户写入授权,切换期间暂停相关收费与出库操作 |
| 2 | Lastleg 结构 | 按数据库变更汇总审阅并执行两个定向文件,核对新增表/字段及免费仓租约束;不重新初始化已有资金、计租表,不重放已有权限种子,不改现行汇率 |
| 3 | Firstleg、Warehouse、Finance | 用匹配后端提交分别发布,核对容器镜像与健康;Firstleg 具备 `/internal/firstleg/v1/fee-items`,Warehouse 具备客户计租 `/pricing`,Finance 接受应收结算方式及独立扣款引用;内部服务地址、统一服务令牌及事件回传配置匹配 |
| 4 | Lastleg | 依赖就绪后发布费用/仓租编排及收发货挂钩;统一 Gateway 下费用分页、项目目录、仓租列表与仓库选项均返回业务成功,不能只检查 HTTP 200 |
| 5 | Admin、海外 PDA | Admin 包含海外费用管理和单据费用操作;海外 PDA 包含 `/pda/v1/last-leg/storage-rent/preparations` 出库预校验。H5 按宿主机静态站点规则发布并核验资源摘要;APK/WGT 按实际设备另行核对,H5 通过不等于设备已更新 |
| 6 | 最小业务验收 | 使用明确授权的新测试样本完成下表;逐笔核对业务单、费用、冻结/消费/退款流水及事件回执。查询成功、表为空或历史事件已投递不能替代新功能闭环 |
第一轮最小样本按以下顺序执行:人工余额收费的登记冻结、审核实扣、原请求重放与驳回释放;账单收费审核入池且钱包不变;免费仓租记录无冻结/扣款;非零仓租出库预占、审核前调价及审核实扣;头程同单同仓补入库不重置库龄、重复实收不增加计租数量。供应商账期/直付与采购专用资金链继续按下表分别验收,不由人工收费样本推定通过。
若结构、服务、页面或设备任一项不匹配,保持相关收费/出库操作暂停并记录失败,不通过补零、伪造回执或重建费用继续;DDL 不做自动回滚,服务回滚仅限明确核对过的同协议同 Schema 版本。页面布局由人工按 1920×1080、100% 缩放提供截图核对。
## 实际环境验收
@@ -65,7 +82,7 @@
## 验证记录
本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调与 PDA 设备验收尚未执行。上线前逐项填写环境、版本、执行时间与证据,未通过项不得标成通过。
本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调及 PDA 验收状态统一维护在[测试环境单机部署与配置契约](../../../docs/测试环境单机部署与配置契约.md)。该主契约逐项记录环境、版本、执行时间与证据,未通过项不得标成通过。
2026-10-22 本地验证记录(使用最终受影响文件;复用已通过且输入未变的结果):
@@ -1,8 +1,10 @@
# 采购双方向履约
> 2026-10-27 起,日常操作与验收以 [采购简化流程](procurement-workflow.md) 为准。下文保留方向字段与原两段样品链的设计记录,旧装箱、面单、两段核费不再作为新流程门禁。
2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。
- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;待审核且无购买、收货事实时平台可核定方向,审核通过后不再通过通用编辑修改方向。
- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;平台审核仅展示客户下单方向,不允许审核改向;采购仓按该方向筛选。审核底部直接取消、拒绝或通过,拒绝必须填写具体原因。
- 起运收货仓按方向读取 Warehouse 权威公共启用仓目录,国内方向只接受 DOMESTIC,进口方向保持海外 RECEIVING 能力约束。实际收货、计划仓更改、实际仓更正及面单寄件地址均校验同一方向。
- 出口流程:审核 → 国内购买 → 国内分次实收并确认 → 装箱 → 国际面单、预估运费冻结、贴单 → 国内国际交寄 → 海外分次实收并确认 → 海外核定实际费用 → 寄海外客户。正常终态仍为 SHIPPED_TO_CUSTOMER,取消、拒绝、异常关闭规则保留。
- 此次沿用单商品、一次购买、两段各整批交寄及 Admin 台账,不新增 WMS 库存、PDA 入账、分批购买或分批交寄。目的地接收地址沿用国际面单地址快照,最终客户地址由下单提供,最终面单变更仍需客户凭证。
@@ -0,0 +1,66 @@
# 海外采购旧流程表退役
2026-10-27 已完成代码分离。只处理海外采购专属模型和业务表,不删除或更新共用财务、资金、File、仓储库存或其他海外业务表。尚未执行目标数据库,也未部署运行服务。
采购专属表从 27 张收敛为 6 张;21 张旧表已从生产 Java 和两份初始化基线解除引用。原只读核查发现的阻塞现已在本次修改中处理,不能在旧版服务上直接执行删表脚本。
## 保留六表
| 表 | 用途 |
|---|---|
| ll_overseas_procurement_order | 采购主单、方向、审核仓库与当前状态 |
| ll_overseas_procurement_status_history | 创建、审核及备注修改流水 |
| ll_overseas_procurement_file | 收货、签收附件归属 |
| ll_procurement_workflow_receipt | 分批实收及照片 |
| ll_procurement_workflow_outbound | 分批出库、物流和签收 |
| ll_procurement_workflow_command | 批次操作幂等及审计 |
## 精确退役清单(21 张)
- `ll_overseas_procurement_fee`
- `ll_procurement_wallet_attempt`
- `ll_procurement_flow`
- `ll_procurement_command`
- `ll_procurement_budget_authorization`
- `ll_procurement_receipt`
- `ll_procurement_disposition`
- `ll_procurement_manifest`
- `ll_procurement_carton`
- `ll_procurement_carton_source`
- `ll_procurement_shipment`
- `ll_procurement_shipment_item`
- `ll_procurement_label_set`
- `ll_procurement_label_item`
- `ll_procurement_estimate_review`
- `ll_procurement_repack`
- `ll_procurement_fee_correction`
- `ll_procurement_fee_correction_item`
- `ll_procurement_control_note`
- `ll_procurement_closure`
- `ll_procurement_closure_item`
## 代码分离
- ProcurementQueryService 只读取主单、附件、审核流水及共用人工海外费用,不再执行旧预算、装箱、面单、运输、异常和采购费用查询。客户投影仍过滤客户、主单、业务类型和应收方向,内部说明不公开。
- 创建、审核、编辑响应不再访问旧采购费用表;仓库直接取审核主单。原响应中的旧区块保留空容器,避免当前前端适配器访问缺失数组,背后没有旧业务表依赖。
- 旧购买、预算核验、物理装箱、运输面单、费用核定及异常处置 Controller/Service/DTO 已删除。新批次接口和独立上传、客户确认、文件权限保持不变。
- 旧采购专属 WalletResultHandler、费用 Mapper/Entity、方向事实 Mapper 和专属退款编号 Repository 已移除;共用资金消费者、Outbox/Inbox 和共用海外费用审核链不修改。
- 共用费用业务白名单仅移除已退役 PROCUREMENT_FEE 子业务实体查表项,OVERSEAS_PROCUREMENT 主单继续沿原共用应收、冻结、审核和实扣链。
- 旧履约 OpenAPI 仅保留当前文件接口与详情投影;七状态流程独立契约继续保留。旧实现测试随旧功能退役;现行分批数量、库存、客户隔离、幂等、费用门禁与权限测试保留,并新增仅六表可运行的真实 Mapper 详情/审核测试和退役范围合同。
## 脚本与执行顺序
1. [只读核查 SQL](../../deploy/sql/overseas-procurement-table-cleanup-review.sql):查看 27 张表存在状态及行数估算,支持重复执行,不需要业务写权限。
2. 完成快照/备份,停止旧服务写入并部署本次 Lastleg 分离版本;在维护窗口验证现行主流程。必须先部署、再删除旧表。不能把脚本存在视为已发布。
3. 审阅 [精确删表 SQL](../../deploy/sql/overseas-procurement-legacy-tables-retire.sql)。在指定 MySQL 8.0+ 实例的同一连接设置脚本声明的发布确认变量,然后整份执行,禁止遇错继续。脚本只对 baiyue_lastleg 的 21 张清单表执行一条 DROP TABLE IF EXISTS;DDL 隐式提交,不承诺事务回滚。
4. 脚本先验证六张当前表存在、无旧状态采购、旧资金表无任何记录,并只读检查共用资金 Outbox/Inbox 中是否仍有 PROCUREMENT_FEE 证据。有旧资金记录就停止,不自动删除、迁移或伪造结清,也不修改共用表。非空旧物理/运输/异常表会随 DROP 永久删除,须在执行前核对备份及范围。
5. 前置检查在首个业务 DROP 前完成;不关闭外键校验,有外部引用则失败。完整成功后可重复执行,已删除表按 IF EXISTS 跳过。失败时停止、保留原错误并核查现状;最终剩余旧表数应为 0,同时确认客户端退出码为 0。
只读脚本不证明数据为空,删除脚本也不能自动识别运行中的二进制版本。V14—V21 旧采购迁移仅用于历史追溯,不得在本次退役后重放;当前 Lastleg 基线与海外空库基线已删除这 21 张定义。不得用本脚本删除六张现用表或任何共用费用、余额、文件对象及库存数据。
## 本次验证
- 后端现行采购及退役契约 55 项、管理端相关 67 项、Gateway 路由 5 项和脚本文档契约 4 项通过;Mapper 扫描通过。
- 两份初始化 SQL 与修改前逐表比较:均只移除这 21 张表,其余表定义完全一致;生产 Java 中没有这 21 张旧表名引用。
- Lastleg 架构检查 15 项中 14 项通过,无新增跨业务依赖;车队检查仍因 dispatch 访问 fleet 持久化 Mapper 失败,该问题不在本次采购修改范围。
- 未执行目标 MySQL、未部署、未进行线上页面验收;SQL 仅完成静态范围及防护契约检查。
@@ -0,0 +1,57 @@
# 海外采购简化流程
2026-10-27 按客户确认的需求收敛采购主流程,适用于海外采购回国内、国内采购发往海外两个方向。
| 主状态 | 编码 | 操作 |
|---|---|---|
| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 |
| 收货中 | `RECEIVING` | 平台分批登记数量和照片 |
| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 |
| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 |
| 运输中 | `IN_TRANSIT` | 平台从采购单创建出库批次,填写数量和物流跟踪号 |
| 派送中 | `DELIVERING` | 平台更新批次派送状态 |
| 签收完成 | `COMPLETED` | 全部采购库存已出完,且所有出库批次已签收 |
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。费用明细缺失或尚未审核时可提示运营补充,但不阻止创建出库批次;出库资格只由采购状态、可用数量、物流跟踪号及操作权限决定。原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。
## 验收范围
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
- 创建采购单后为待审核,审核后直接收货中。
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。
- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。
- 无费用、费用尚未审核或驳回时均允许创建出库;页面提示不影响出库操作。
自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。
## 2026-10-27 本地验证记录
- 平台端定向检查104项通过,客户端定向检查28项通过;两端 TypeScript 检查通过。
- 后端七状态、审核、权限、OpenAPI、结构中文备注40项去重定向检查通过;共用费用服务44项、采购费用来源4项、客户详情投影7项、费用真实Mapper持久化11项通过。
- 流程测试使用隔离内存数据库和真实MyBatis Mapper,覆盖两次收货、客户确认、两批出库、派送及签收扣减,以及超量、并发、重试、版本与文件归属。
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。
## 2026-10-29 客户下单预算与页面调整
客户采购下单前,以相同幂等键调用 `order-budgets/procurement/{key}`,金额为预算单价乘数量,按有效汇率折人民币;仅Finance冻结成功且usable=true时允许建单。余额不足使用可用授信,合计不足则提示Finance原因,不创建订单。免费样品金额为0时不发资金指令。未绑定预占15分钟后释放,晚到回执仍释放;已绑定订单终态释放,失败定时重试。预算预占独立于现有人工费用冻结/实扣,不计入已扣款汇总。
本土卡车同样校验预算,托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。
结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。
@@ -64,6 +64,8 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService'
&& ['com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort', 'com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels'].includes(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
@@ -74,14 +76,17 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService',
'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService',
'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController',
'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source)
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort'
|| [
'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->reporting.TripOperationsEvidenceGateway',
'TransportTaskService->photo.FleetResourcePhotoModels',
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
@@ -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);
}
}

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