Compare commits

..
79 Commits
Author SHA1 Message Date
liuyihe 94a36b8c4c 提交 2026-11-04 19:26:27 +08:00
ouyangxq 51291b4c27 优化 2026-11-04 18:08:19 +08:00
liuyihe 0993dd446e 提交 2026-11-04 15:55:55 +08:00
ouyangxq fbdf58c23c 补齐海外仓和私人地址正常及短少收货真实验收 2026-11-04 14:23:41 +08:00
ouyangxq 40475388e1 支持私人地址按实际收货生成库存凭证 2026-11-04 13:55:24 +08:00
ouyangxq 22e8939f1f 修复私人地址逐箱收货的目的地匹配 2026-11-04 13:45:59 +08:00
ouyangxq a01598bd21 允许私人地址按独立托盘生成海外入库计划 2026-11-04 13:35:23 +08:00
ouyangxq 0d8e64c42c 支持私人地址装柜后按普通海外收货交接 2026-11-04 12:45:05 +08:00
ouyangxq 8b56af2cae 统一人工报价时间精度以保证原键精确重放 2026-11-04 12:28:47 +08:00
liuyihe 12617e35a0 Merge remote-tracking branch 'origin/test' into test 2026-11-04 12:22:26 +08:00
liuyihe 46bc137e99 提交 2026-11-04 12:21:59 +08:00
ouyangxq 4165dc3403 将国内初始化成功标记归位到全部语句末尾 2026-11-04 12:17:23 +08:00
ouyangxq 6e451a82da 支持私人地址人工头程报价以闭合预装费用前置 2026-11-04 12:10:14 +08:00
ouyangxq 78ecd2e039 同步派送异常退回订单类型的测试断言 2026-11-04 11:26:13 +08:00
liuyihe 3b1e38cf90 Merge remote-tracking branch 'origin/test' into test 2026-11-04 11:13:44 +08:00
liuyihe ba49035942 提交 2026-11-04 11:13:29 +08:00
ouyangxq 91e4fc33ff 补齐Lastleg回归夹具与现行接口合同断言 2026-11-04 11:12:01 +08:00
liuyihe 413e1e83e8 提交 2026-11-04 10:39:00 +08:00
ouyangxq c3a6519ce4 修正Lastleg废弃箱唛字段的初始化归属 2026-11-04 10:24:39 +08:00
ouyangxq 2eb0b65ba5 修复海外散货托盘收货与短少入库闭环 2026-11-04 09:57:14 +08:00
liuyihe b95281bacb 提交 2026-11-03 16:56:14 +08:00
liuyihe 5d6b2812cf 提交 2026-11-03 16:51:30 +08:00
liuyihe 1506576c67 提交 2026-11-03 16:14:51 +08:00
liuyihe 7b5ef9ba64 提交 2026-11-03 11:41:58 +08:00
liuyihe 21a9ce7d27 提交 2026-11-02 17:14:02 +08:00
ouyangxq 035e7e06af 修复费用审核前调整和运输取消及对账明细约束 2026-10-30 18:51:28 +08:00
liuyihe 5825739b92 提交 2026-10-30 18:46:44 +08:00
liuyihe edd4b1cd38 提交 2026-10-30 18:46:18 +08:00
liuyihe 51a93a69af 提交 2026-10-30 17:59:12 +08:00
ouyangxq 872215367b 修复废弃预留重放与预算到期时区 2026-10-30 15:42:39 +08:00
liuyihe ac013c265b 提交 2026-10-30 13:48:20 +08:00
liuyihe e1bfcfc8f5 提交 2026-10-30 11:54:49 +08:00
liuyihe 0bd25de906 提交 2026-10-29 15:49:18 +08:00
liuyihe 849f8dc5f2 提交 2026-10-29 14:29:02 +08:00
liuyihe 1b27d7e6c2 提交 2026-10-29 13:36:38 +08:00
ouyangxq 353c86f4cf 为船期节点记录并返回操作人快照 2026-10-28 16:56:33 +08:00
liuyihe 71a1026e21 提交 2026-10-28 14:44:59 +08:00
liuyihe f3d57869f2 费用提交 2026-10-28 13:57:34 +08:00
liuyihe 7086713fdd Merge remote-tracking branch 'origin/test' into test 2026-10-28 13:42:18 +08:00
liuyihe 66b0935b11 费用提交 2026-10-28 13:42:03 +08:00
ouyangxq 4f2d90b490 完善预约货物资料与可选确认时间并修复排车衔接 2026-10-28 11:57:09 +08:00
liuyihe 96ffc0925a 费用提交 2026-10-28 11:29:38 +08:00
ouyangxq ca61f79b6d 修正外箱快照清空并提供当前计费量只读预览 2026-10-28 11:03:44 +08:00
ouyangxq b2e4f0d246 统一设备与人工测量默认算法和外箱计费快照 2026-10-28 10:30:40 +08:00
ouyangxq 0b13c2cfb7 修正排车照片端口与车次模型依赖合同 2026-10-28 10:26:52 +08:00
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
ouyangxq 9f7cac22b3 test(lastleg): wire storage rent gate in merged picking fixture 2026-10-26 15:38:11 +08:00
liuyihe 3f8a0df433 费用提交 2026-10-26 15:35:14 +08:00
ouyangxq 17cf05c4e1 fix(db): correct overseas initialization table count 2026-10-26 15:29:51 +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
ouyangxq f37d52aead Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-10-26 13:58:42 +08:00
ouyangxq 98f7d81bb1 优化 2026-10-26 13:58:40 +08:00
admin 5e9cebcf85 Merge pull request 'fix(firstleg): 一件代发待组托允许确认并发送费用' (#34) from test into main 2026-10-26 11:46:35 +08:00
614 changed files with 22020 additions and 11388 deletions
+1 -1
View File
@@ -55,7 +55,7 @@
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` | | 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` | | 海外业务费用来源跨区入池、人工费用与自动仓租 | `lastleg-service/`(`financefee/`、`storagerent/`)、`common-event-contract/`、`finance/` |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | | 供应商、国内整柜库存与出入库 | `warehouse/` |
@@ -11,7 +11,14 @@
两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `CREATE TABLE` 中,菜单及授权使用初始化种子;不追加存量改造、权限版本回填或动态迁移段。目标 schema 必须为空,按文件原有初始化流程执行;不能向已有业务库重放。 两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `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 隐式提交,不能宣称整体可回滚;失败保留错误并只读复查,已完成项不删除重建。两步是已核缺项的补齐方案,不是任意版本升级工具;重新执行前仍需核对目标实例与结构。
## 海外表和字段 ## 海外表和字段
File diff suppressed because it is too large Load Diff
+35 -6
View File
@@ -1,9 +1,35 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
`20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
散板收货闭环:`migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql` 为已有 Lastleg 库增加入仓方式、收货仓、联系人、预计到仓、物流号及转运关联字段;新库使用同步后的海外统一基线。IAM 使用 `migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql` 补齐“散板需求”菜单及“加入散板货物”按钮,并为既有平台管理员补取货排车创建权限;权限源同步于 `iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql` 和国内统一基线。两项已在本机应用并通过两场景HTTP验收,未据此认定TEST或生产已部署;已有库不可重放空库基线,V37执行前须核对九个新增字段尚不存在。
`migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql` 修复海外人工费用资金重试报 S0500:仅将 `baiyue_lastleg.ll_finance_fee_command.action` 扩为 `VARCHAR(32)`,容纳 20 字符的 `MANUAL_FUNDING_RETRY`,保留中文备注、非空及原幂等唯一键。Lastleg 源基线和海外空库基线同步。已有环境先核对目标库及列定义,经授权执行 V35、回查列长度和记录总数,再通过原费用重试入口验证;可重复执行,不修改费用、审核、余额或资金记录,不重放空库基线。DDL 隐式提交,遇错停止;本次仅准备修复文件,尚未执行测试环境 SQL 或部署,文件存在不表示已经执行。
`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
`finance-reconciliation-line-uniqueness-repair.sql` 修正 TEST 作废客户对账单后原费用无法重新生成对账单的问题:以一条原子 ALTER 将 `baiyue_finance.fin_bill_line` 的费用版本全历史唯一键改为单内唯一键,并保留费用来源普通索引。作废明细和金额快照完整保留;有效费用占用继续由 Finance 生成时的行锁、AVAILABLE → LOCKED 状态校验及作废单重开校验保证。模块基线与统一空库基线同步。先核对上述服务校验及目标表结构,经单独授权执行,再核对结构和记录总数;遇错停止,结果不明确先回查,不盲目重放,不更新业务数据或重放空库基线。脚本存在不表示已执行。
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
`overseas-procurement-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 或部署。 放舱后预计到港窄维护需要配套更新 `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 或部署。 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
船节点日志在 `fl_carrier_schedule_node_fact.operator_id/operator_name` 保存操作时的账号与用户名快照;人工补录和确认出仓写入,船司自动采集为空。两列定义同步于 Firstleg 船节点模块基线与国内统一空库基线,更新结构后配套发布 Firstleg 与 Admin。既有 TEST 的字段变更须单独授权执行,不重放空库初始化,不回填历史节点。
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。 `overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。 `overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
@@ -28,7 +54,7 @@
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。 本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。 `migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_0900_ai_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。 `migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
@@ -48,11 +74,11 @@
`reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`,并首次创建 `ll_fba_appointment_command` 保存同事务幂等结果与历史。执行任何DROP前必须确认命令表尚不存在;若已存在,先核对上次执行结果,本脚本不清空命令历史、不支持盲目重试。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接及两张表的具体重置授权;不得作为自动启动脚本或用于生产。主单区分计划日期/时段、系统单号、外部确认号和确认UTC/IANA时区;明细按稳定货源占用,不再依赖入仓计划明细。结构必须与匹配Lastleg和Admin一同发布,失败立即停止,不回填旧数据。权限从IAM种子核对;文件存在不表示已执行。 `reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`,并首次创建 `ll_fba_appointment_command` 保存同事务幂等结果与历史。执行任何DROP前必须确认命令表尚不存在;若已存在,先核对上次执行结果,本脚本不清空命令历史、不支持盲目重试。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接及两张表的具体重置授权;不得作为自动启动脚本或用于生产。主单区分计划日期/时段、系统单号、外部确认号和确认UTC/IANA时区;明细按稳定货源占用,不再依赖入仓计划明细。结构必须与匹配Lastleg和Admin一同发布,失败立即停止,不回填旧数据。权限从IAM种子核对;文件存在不表示已执行。
`migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_unicode_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。 `migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_0900_ai_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。
`migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。 `migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。 内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_0900_ai_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。 平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
@@ -77,7 +103,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 | | 国内 | `头程数据库统一增量-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` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -100,7 +126,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 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 +227,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、回填或删除测试存量。 编号规则见 `../../../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)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。 采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
@@ -0,0 +1,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);
@@ -0,0 +1,10 @@
-- 仅用于单独授权的 TEST:修正作废对账单历史明细阻止原费用重新生成对账单的问题。
-- 固定目标 MySQL 8 / InnoDB;保留全部账单、明细、金额及修订,不更新、删除或回填业务记录。
-- 单内同一费用版本保持唯一;跨单有效占用由 Finance 生成时的费用行锁和状态校验保证。
-- 前置:Finance 已具备生成时 AVAILABLE -> LOCKED 校验,以及作废单重开时的来源重新占用校验。
-- 执行前后核对 SHOW CREATE TABLE 与记录总数;本文件存在不表示已经执行。
-- 使用独立连接并开启遇错停止;一条原子 ALTER 替换索引,结果不明确先回查,不盲目重放。
ALTER TABLE baiyue_finance.fin_bill_line
DROP INDEX uk_fin_bill_line_source,
ADD UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
ADD KEY idx_fin_bill_line_source (source_type, source_id, source_version);
@@ -4,4 +4,4 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date) PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车单号每日独立原子流水';
@@ -3,6 +3,16 @@
-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。 -- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。
USE baiyue_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( SET @dispatch_transport_column_ddl = IF(
EXISTS ( EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
@@ -59,7 +69,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY (business_date) PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task ( CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
@@ -71,13 +81,13 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
UNIQUE KEY uk_transport_trip (trip_no), UNIQUE KEY uk_transport_trip (trip_no),
UNIQUE KEY uk_transport_source (active_order), UNIQUE KEY uk_transport_source (active_order),
KEY idx_transport_order (customer_id, order_no) KEY idx_transport_order (customer_id, order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create ( CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要', request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要',
PRIMARY KEY (trip_no) PRIMARY KEY (trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command ( CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
@@ -86,7 +96,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY (dispatch_no, request_key) PRIMARY KEY (dispatch_no, request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车运输执行命令与凭证账本';
-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。 -- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。
SET @dispatch_transport_pool_ddl = IF( SET @dispatch_transport_pool_ddl = IF(
@@ -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='车辆及司机档案照片版本记录';
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_channel (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code), UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status) KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing_template ( CREATE TABLE IF NOT EXISTS fl_sailing_template (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_template (
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing ( CREATE TABLE IF NOT EXISTS fl_sailing (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -100,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing (
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code), KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing_history ( CREATE TABLE IF NOT EXISTS fl_sailing_history (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/* /*
* 船期日历的事实契约。 * 船期日历的事实契约。
@@ -0,0 +1,13 @@
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
@@ -2,6 +2,6 @@
-- Schema: baiyue_lastleg -- Schema: baiyue_lastleg
-- Purpose: 拼车履约客户关联字段与现有订单域排序规则一致 -- Purpose: 拼车履约客户关联字段与现有订单域排序规则一致
-- Preconditions: 外接预订和商业履约桥已存在,客户字段为VARCHAR(128)且非空 -- Preconditions: 外接预订和商业履约桥已存在,客户字段为VARCHAR(128)且非空
-- Postconditions: 两个客户关联字段使用utf8mb4_unicode_ci;不修改客户标识或业务状态 -- Postconditions: 两个客户关联字段使用utf8mb4_0900_ai_ci;不修改客户标识或业务状态
ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属'; ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '委托客户及付款归属';
ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围'; ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '商业客户隔离范围';
@@ -1,8 +1,8 @@
-- Region: OVERSEAS -- Region: OVERSEAS
-- Schema: baiyue_lastleg -- Schema: baiyue_lastleg
-- Purpose: 仓储转运来货审核生成入仓计划时允许完整WAREHOUSE_TRANSFER来源值 -- Purpose: 仓储转运来货审核生成入仓计划时允许完整WAREHOUSE_TRANSFER来源值
-- Preconditions: ll_inbound_plan.creation_mode现为VARCHAR(16),utf8mb4_unicode_ci、NOT NULL、DEFAULT AUTO -- Preconditions: ll_inbound_plan.creation_mode现为VARCHAR(16),utf8mb4_0900_ai_ci、NOT NULL、DEFAULT AUTO
-- Postconditions: 仅将本列扩至VARCHAR(32);保留排序规则、非空、默认值和已有业务数据 -- Postconditions: 仅将本列扩至VARCHAR(32);保留排序规则、非空、默认值和已有业务数据
ALTER TABLE ll_inbound_plan ALTER TABLE ll_inbound_plan
MODIFY COLUMN creation_mode VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci MODIFY COLUMN creation_mode VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'; NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运';
@@ -45,4 +45,4 @@ CREATE TABLE ll_procurement_wallet_attempt (
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
KEY idx_procurement_attempt_order(customer_id,order_id,status), KEY idx_procurement_attempt_order(customer_id,order_id,status),
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用资金命令尝试与不可变结果';
@@ -31,7 +31,7 @@ CREATE TABLE ll_procurement_flow (
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), 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_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=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='采购单一批次履约事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购单一批次履约事实';
CREATE TABLE ll_procurement_command ( CREATE TABLE ll_procurement_command (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
@@ -42,7 +42,7 @@ CREATE TABLE ll_procurement_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
KEY idx_procurement_command_customer(customer_id,order_id,created_at) KEY idx_procurement_command_customer(customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购节点幂等与不可变审计';
CREATE TABLE ll_procurement_budget_authorization ( CREATE TABLE ll_procurement_budget_authorization (
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
@@ -54,4 +54,4 @@ CREATE TABLE ll_procurement_budget_authorization (
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), 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_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')) 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='采购货款预算授权核验历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购货款预算授权核验历史';
@@ -10,7 +10,7 @@ CREATE TABLE ll_procurement_receipt (
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), 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_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)) 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='采购分次实收与受控更正事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购分次实收与受控更正事实';
CREATE TABLE ll_procurement_disposition ( 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 '客户隔离键', id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -22,7 +22,7 @@ CREATE TABLE ll_procurement_disposition (
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), 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) CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购单内差异处置,不自动退款';
CREATE TABLE ll_procurement_manifest ( 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 '客户隔离键', id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -31,7 +31,7 @@ CREATE TABLE ll_procurement_manifest (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购装箱清单不可变版本';
CREATE TABLE ll_procurement_carton ( CREATE TABLE ll_procurement_carton (
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
@@ -40,12 +40,12 @@ CREATE TABLE ll_procurement_carton (
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT 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), 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) 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='采购版本化逐箱实测与件数'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购版本化逐箱实测与件数';
CREATE TABLE ll_procurement_carton_source ( CREATE TABLE ll_procurement_carton_source (
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', 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) 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='采购箱内货物的实收来源与数量'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购箱内货物的实收来源与数量';
CREATE TABLE ll_procurement_shipment ( 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 '客户隔离键', id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -55,11 +55,11 @@ CREATE TABLE ll_procurement_shipment (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), 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')) CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购两段各一次整批真实交寄';
CREATE TABLE ll_procurement_shipment_item ( 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 '本次实际交寄件数', 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 '运单号快照', carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) 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='采购整批交寄逐箱事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购整批交寄逐箱事实';
@@ -7,7 +7,7 @@ CREATE TABLE ll_procurement_label_set (
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购两段面单不可变版本和地址快照';
CREATE TABLE ll_procurement_label_item ( CREATE TABLE ll_procurement_label_item (
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
@@ -15,14 +15,14 @@ CREATE TABLE ll_procurement_label_item (
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', 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 '贴单确认人', 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) 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='采购逐箱文件页关联及贴单事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购逐箱文件页关联及贴单事实';
CREATE TABLE ll_procurement_estimate_review ( 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 '已核对国际面单版本', 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 '核价人员', fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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) PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='面单清单与预估冻结的显式核对关联';
CREATE TABLE ll_procurement_repack ( 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,保持一对一来源', id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
@@ -32,9 +32,9 @@ CREATE TABLE ll_procurement_repack (
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), 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) 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='采购国内原箱一对一换包装不可变历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购国内原箱一对一换包装不可变历史';
ALTER TABLE ll_overseas_procurement_file ALTER TABLE ll_overseas_procurement_file
DROP CHECK chk_ll_procurement_file_category, DROP CHECK chk_ll_procurement_file_category,
MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证',
ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION')); ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'));
@@ -12,9 +12,9 @@ CREATE TABLE ll_procurement_fee_correction (
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), 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), 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) 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='采购费用链更正目标与原汇率快照'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用链更正目标与原汇率快照';
CREATE TABLE ll_procurement_fee_correction_item ( CREATE TABLE ll_procurement_fee_correction_item (
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', 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) PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
@@ -133,15 +133,15 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
id bigint NOT NULL COMMENT '测量照片引用ID', id bigint NOT NULL COMMENT '测量照片引用ID',
transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID', transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', customer_id varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户租户ID',
file_id bigint NOT NULL COMMENT '文件服务文件ID', file_id bigint NOT NULL COMMENT '文件服务文件ID',
file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照', file_name varchar(255) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件名快照',
content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照', content_type varchar(128) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件媒体类型快照',
size_bytes bigint NOT NULL DEFAULT 0 COMMENT '文件字节数快照', size_bytes bigint NOT NULL DEFAULT 0 COMMENT '文件字节数快照',
uploaded_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人', uploaded_by varchar(128) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'PDA上传操作人',
uploaded_at datetime(3) NOT NULL COMMENT '上传时间', uploaded_at datetime(3) NOT NULL COMMENT '上传时间',
submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间', submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id), UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id),
KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at) KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA测量现场照片引用';
@@ -10,7 +10,7 @@ CREATE TABLE ll_procurement_control_note (
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL 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 '本单处理凭证', 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) 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='采购不可覆盖事实的更正说明及阻断异常'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
CREATE TABLE ll_procurement_closure ( 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 '客户隔离键', id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -19,7 +19,7 @@ CREATE TABLE ll_procurement_closure (
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) 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 '关闭确认时间', 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) 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='采购实物资金收口后的异常关闭事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购实物资金收口后的异常关闭事实';
CREATE TABLE ll_procurement_closure_item ( CREATE TABLE ll_procurement_closure_item (
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
@@ -28,4 +28,4 @@ CREATE TABLE ll_procurement_closure_item (
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT 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), PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
@@ -2,43 +2,43 @@
-- Schema: baiyue_lastleg -- Schema: baiyue_lastleg
-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求 -- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求
-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突 -- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_unicode_ci;不修改数量、状态或业务标识 -- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_0900_ai_ci;不修改数量、状态或业务标识
-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。 -- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。
ALTER TABLE ll_return_order ALTER TABLE ll_return_order
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID'; MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作海外仓ID';
ALTER TABLE ll_return_order_item ALTER TABLE ll_return_order_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名'; MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_receipt ALTER TABLE ll_return_receipt
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号'; MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '签收单号';
ALTER TABLE ll_return_receipt_item ALTER TABLE ll_return_receipt_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码'; MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码';
ALTER TABLE ll_return_inventory_carton ALTER TABLE ll_return_inventory_carton
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '海外仓ID',
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码'; MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '退货箱号和箱唛编码';
ALTER TABLE ll_return_inventory_carton_item ALTER TABLE ll_return_inventory_carton_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名'; MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_inventory_outbound_movement ALTER TABLE ll_return_inventory_outbound_movement
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键', MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID', MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '实际出库海外仓ID',
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照', MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次出库SKU编码快照',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照', MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号'; MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次退货出库业务单号';
ALTER TABLE ll_return_sku_conversion ALTER TABLE ll_return_sku_conversion
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作海外仓ID',
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU', MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标前SKU',
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU', MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标后SKU',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照', MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号'; MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标单号';
@@ -13,4 +13,4 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id), UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒', last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key) PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海外业务短单号分配时钟';
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 -- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米''')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
@@ -25,9 +25,9 @@ PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。 -- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -35,36 +35,36 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 生产环境当前缺失、测试已存在的换标与退货字段。 -- 生产环境当前缺失、测试已存在的换标与退货字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF( SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')) EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci'))
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci')),
'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''原海外中转仓ID''', 'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''原海外中转仓ID''',
'SELECT 1' 'SELECT 1'
); );
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF( SET @ddl = IF(
(SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_unicode_ci', (SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_0900_ai_ci',
'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', 'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci',
'SELECT 1' 'SELECT 1'
); );
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -8,11 +8,11 @@ CREATE TABLE ll_dispatch_truck_task (
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order), PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
KEY idx_transport_order(customer_id,order_no) KEY idx_transport_order(customer_id,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE ll_dispatch_transport_create ( CREATE TABLE ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号', trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要' request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE ll_dispatch_transport_command ( CREATE TABLE ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
@@ -20,4 +20,4 @@ CREATE TABLE ll_dispatch_transport_command (
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key) PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci 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,10 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 预算释放查询的客户和订单号关联字段与采购单、卡车单排序规则一致
-- Preconditions: ll_order_budget_reservation 已存在;核对列长度、可空性及索引
-- Postconditions: customer_id、order_no 使用 utf8mb4_0900_ai_ci;不修改任何业务数据
-- DDL 非事务,执行前核对目标库;失败后检查列定义再继续。
ALTER TABLE baiyue_lastleg.ll_order_budget_reservation
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户隔离标识',
MODIFY COLUMN order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '成功创建的业务单号';
@@ -0,0 +1,10 @@
ALTER TABLE ll_local_looseboard_order
ADD COLUMN delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货',
ADD COLUMN operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID',
ADD COLUMN destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址',
ADD COLUMN destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编',
ADD COLUMN delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人',
ADD COLUMN delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话',
ADD COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
ADD COLUMN tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号',
ADD COLUMN transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号';
@@ -8,5 +8,5 @@ ALTER TABLE ll_outbound_pda_pick
MODIFY COLUMN inventory_position_id bigint NULL COMMENT '库存仓位ID;退货箱直接出库时为空'; MODIFY COLUMN inventory_position_id bigint NULL COMMENT '库存仓位ID;退货箱直接出库时为空';
ALTER TABLE ll_relabel_item ALTER TABLE ll_relabel_item
MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空', MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空',
MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空'; MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空';
@@ -27,4 +27,4 @@ CREATE TABLE ll_warehouse_transfer_receipt_command (
UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key), UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key),
KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id), KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id),
CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED')) CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令';
@@ -0,0 +1,83 @@
-- 海外费用菜单定向修复,MySQL 8.0+,目标只能 baiyue_iam。
-- 将“海外费用管理”改为“海外费用明细”,并挂到“财务管理”下。
-- 权限码、路由和角色授权保持不变;可重复执行。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS repair_overseas_fee_menu_parent;
DELIMITER $$
CREATE PROCEDURE repair_overseas_fee_menu_parent()
BEGIN
DECLARE v_menu_changed INT DEFAULT 0;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
IF (SELECT COUNT(*) FROM iam_permission
WHERE id=5105000 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:finance'
AND status='ACTIVE' AND deleted=0)<>1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Finance parent menu platform:finance is missing or inactive';
END IF;
IF EXISTS (SELECT 1 FROM iam_permission
WHERE (id=5117200 AND (permission_code<>'platform:overseas-fee:list'
OR tenant_id<>0 OR app_code<>'PLATFORM' OR permission_type<>'MENU'))
OR (permission_code='platform:overseas-fee:list'
AND (id<>5117200 OR tenant_id<>0 OR app_code<>'PLATFORM'
OR permission_type<>'MENU'))) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Overseas fee menu permission identity conflict';
END IF;
IF (SELECT COUNT(*) FROM iam_permission
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list')<>1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Overseas fee menu platform:overseas-fee:list is missing';
END IF;
START TRANSACTION;
UPDATE iam_permission
SET parent_id=5105000,
permission_name='海外费用明细',
route_path='/overseas-business/fees',
http_method='GET',
sort_no=60,
status='ACTIVE',
deleted=0,
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list'
AND (parent_id<>5105000 OR permission_name<>'海外费用明细'
OR route_path<>'/overseas-business/fees' OR http_method<>'GET'
OR sort_no<>60 OR status<>'ACTIVE' OR deleted<>0);
SET v_menu_changed=ROW_COUNT();
-- 菜单实际变化时递增已获该菜单权限用户的安全版本,刷新旧授权快照。
IF v_menu_changed>0 THEN
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
SELECT DISTINCT u.auth_user_id,1
FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0
JOIN iam_role r ON r.id=mr.role_id AND r.tenant_id=0
JOIN iam_role_permission rp ON rp.role_id=r.id
AND rp.permission_id=5117200 AND rp.tenant_id=0
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
AND m.status='ACTIVE' AND m.deleted=0
AND r.app_code='PLATFORM' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
END IF;
COMMIT;
END$$
DELIMITER ;
CALL repair_overseas_fee_menu_parent();
DROP PROCEDURE repair_overseas_fee_menu_parent;
SELECT id,parent_id,permission_code,permission_name,route_path,sort_no
FROM iam_permission WHERE id IN (5105000,5117200) ORDER BY id;
@@ -0,0 +1,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_0900_ai_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_0900_ai_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; USE baiyue_lastleg;
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS
+4 -4
View File
@@ -52,7 +52,7 @@ CREATE TABLE ll_inbound_fba_appointment (
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no), UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code), KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status) KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FBA预约安排与正式确认唯一主记录';
CREATE TABLE ll_inbound_fba_appointment_item ( CREATE TABLE ll_inbound_fba_appointment_item (
id BIGINT NOT NULL COMMENT '记录主键', id BIGINT NOT NULL COMMENT '记录主键',
@@ -74,7 +74,7 @@ CREATE TABLE ll_inbound_fba_appointment_item (
KEY idx_ll_fba_active_order (customer_id,order_id,active), KEY idx_ll_fba_active_order (customer_id,order_id,active),
KEY idx_ll_fba_item_source_lookup (source_key,customer_id), KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
KEY idx_ll_fba_item_customer_order (customer_id,order_id) KEY idx_ll_fba_item_customer_order (customer_id,order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入仓前后共用的预约真实货源';
CREATE TABLE ll_appointment_cargo_verification ( CREATE TABLE ll_appointment_cargo_verification (
id BIGINT NOT NULL COMMENT '核实记录主键', id BIGINT NOT NULL COMMENT '核实记录主键',
@@ -97,7 +97,7 @@ CREATE TABLE ll_appointment_cargo_verification (
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key), UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0), CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0) CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
CREATE TABLE ll_fba_appointment_command ( CREATE TABLE ll_fba_appointment_command (
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键', command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
@@ -109,4 +109,4 @@ CREATE TABLE ll_fba_appointment_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
PRIMARY KEY(command_key), PRIMARY KEY(command_key),
KEY idx_ll_fba_command_appointment(appointment_no,created_at) KEY idx_ll_fba_command_appointment(appointment_no,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约命令与变更历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='预约命令与变更历史';
@@ -0,0 +1,13 @@
-- 在 Lastleg 所属库执行;只补充整托计租截止日,不改写既有费用、库存、冻结或资金流水。
-- 发布新服务前暂停仓租出库,取消未消费预占;旧比例费用的处理需按业务单核对后单独办理。
USE baiyue_lastleg;
SET @return_rent_ddl = (
SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'll_storage_rent_lot' AND column_name = 'charged_days'
), 'SELECT 1',
'ALTER TABLE ll_storage_rent_lot ADD COLUMN charged_days BIGINT NOT NULL DEFAULT 0 COMMENT ''退货原托已生成仓租的截止库龄天数,同一天不重复计费'' AFTER original_units')
);
PREPARE return_rent_statement FROM @return_rent_ddl;
EXECUTE return_rent_statement;
DEALLOCATE PREPARE return_rent_statement;
@@ -3093,7 +3093,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码', seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码',
remark varchar(1000) DEFAULT NULL COMMENT '证据备注', remark varchar(1000) DEFAULT NULL COMMENT '证据备注',
status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废', status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,保证每柜每类型仅一条有效事实', active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,同类允许多张有效照片',
operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键', operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason varchar(500) DEFAULT NULL COMMENT '作废原因', void_reason varchar(500) DEFAULT NULL COMMENT '作废原因',
@@ -3109,7 +3109,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key), UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag), KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_container (container_booking_id,status), KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag), KEY idx_cabinet_evidence_seal (seal_no,active_flag),
CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id),
@@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称', location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用', source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id bigint unsigned DEFAULT NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name varchar(128) DEFAULT NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -4142,7 +4144,7 @@ CREATE TABLE fl_direct_delivery_unloading (
PRIMARY KEY (container_booking_id,inbound_id), PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key), UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直送目的仓真实卸货凭据';
CREATE TABLE fl_terminal_drayage ( CREATE TABLE fl_terminal_drayage (
@@ -4763,7 +4765,8 @@ CREATE TABLE fin_bill_line (
currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type,source_id,source_version), UNIQUE KEY uk_fin_bill_line_bill_source (bill_id,source_type,source_id,source_version),
KEY idx_fin_bill_line_source (source_type,source_id,source_version),
KEY idx_fin_bill_line_bill (tenant_id,bill_id), KEY idx_fin_bill_line_bill (tenant_id,bill_id),
CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)), CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)),
CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')), CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')),
@@ -5044,6 +5047,7 @@ CREATE TABLE fin_settlement (
due_date date NOT NULL COMMENT '结算到期日期', due_date date NOT NULL COMMENT '结算到期日期',
payment_status varchar(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', 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已作废', 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 '乐观锁版本号', version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID',
@@ -5051,7 +5055,7 @@ CREATE TABLE fin_settlement (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_no (tenant_id,settlement_no), 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_customer_direction_status (tenant_id,direction,status),
KEY idx_fin_settlement_supplier_status (supplier_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), KEY idx_fin_settlement_receivable_statistics (direction,currency_code,generated_date,status,tenant_id),
@@ -8066,7 +8070,7 @@ CREATE TABLE fl_sailing_channel (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code), UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status) KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_template ( CREATE TABLE fl_sailing_template (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -8102,7 +8106,7 @@ CREATE TABLE fl_sailing_template (
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing ( CREATE TABLE fl_sailing (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -8139,7 +8143,7 @@ CREATE TABLE fl_sailing (
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code), KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_history ( CREATE TABLE fl_sailing_history (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -8153,7 +8157,7 @@ CREATE TABLE fl_sailing_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
USE baiyue_iam; USE baiyue_iam;
@@ -8449,7 +8453,7 @@ CREATE TABLE ord_document_sequence (
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key), PRIMARY KEY (sequence_key),
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0) CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务单据每日独立原子流水';
USE baiyue_warehouse; USE baiyue_warehouse;
CREATE TABLE wh_document_sequence ( CREATE TABLE wh_document_sequence (
@@ -8457,10 +8461,24 @@ CREATE TABLE wh_document_sequence (
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key), PRIMARY KEY (sequence_key),
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0) CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务单据每日独立原子流水';
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。 -- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
USE baiyue_iam; USE baiyue_iam;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
USE baiyue_iam;
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
File diff suppressed because it is too large Load Diff
View File
@@ -7,13 +7,17 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory { 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_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_TRIP_OPERATION_EVIDENCE(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_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_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), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -80,7 +84,8 @@ enum BusinessFileCategory {
&& tenantId != 0) { && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
} }
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE) if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE
|| this == OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE || this == OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE)
&& tenantId != 0) { && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
} }
@@ -90,7 +95,7 @@ enum BusinessFileCategory {
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) { if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台"); 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, "车辆运维照片必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
} }
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) { if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
@@ -7,7 +7,7 @@ info:
复用InternalBusinessFileController,不增加公网File路径。 复用InternalBusinessFileController,不增加公网File路径。
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO, businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。 tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。 装车资料接受非空JPG/PNG/PDF(Lastleg仅允许司机驾驶证类别使用PDF),油耗仍仅接受JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、 Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。 分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。 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 { class BusinessFileStorageTest {
@TempDir Path root; @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() { @Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
BusinessFileStorage storage = storage(); 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);
}
}
}
@@ -0,0 +1,19 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class LooseboardTransportPhotoFileTest {
/** 两节点文件独立归属原排车,客户文件及PDF不能充当现场照片。 */
@Test void photosHaveDistinctPlatformOwnershipAndImagePolicy(){
var pickup=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE");
var arrival=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE");
assertThat(pickup).isNotEqualTo(arrival);
for(var category:new BusinessFileCategory[]{pickup,arrival}){
category.validateOwner(0);assertThatThrownBy(()->category.validateOwner(100)).hasMessageContaining("平台");
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
assertThat(category.storageBusinessId("100")).isEqualTo("100");
assertThatThrownBy(()->category.storageBusinessId("../100")).isInstanceOf(RuntimeException.class);
}
}
}
@@ -45,8 +45,9 @@ class OwnedBusinessPhotoFileTest {
verifyNoInteractions(files, storage); verifyNoInteractions(files, storage);
} }
/** 装车驾驶证允许 PDF;油耗仍限图片,两类都拒绝空文件、伪造内容和超限文件。 */
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) @ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception { void enforcesLoadingAndFuelFormatPoliciesAndRejectsInvalidFiles(String type) throws Exception {
var storage = new BusinessFileStorage(); var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root); ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot(); storage.initializeRoot();
@@ -60,8 +61,14 @@ class OwnedBusinessPhotoFileTest {
for (var upload : new MockMultipartFile[]{ for (var upload : new MockMultipartFile[]{
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]), new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()), 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); 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); MultipartFile oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false); when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1); 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.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Options;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */ /** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
@Mapper @Mapper
public interface FinanceFeeSourceReadMapper { 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 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" +
String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END"; " 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 BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
String FILTER = " FROM fin_fee_source f WHERE 1=1 " + String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
"<if test=\"status == null or status != 'SUPERSEDED'\">" + "<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>") " ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<Map<String, Object>> list(Map<String, Object> filters); 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" + 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.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" + " 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; package com.baiyue.finance.repository;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; 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.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper; import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
import com.baiyue.finance.service.FinanceEventException; import com.baiyue.finance.service.FinanceEventException;
@@ -51,6 +52,21 @@ public class FinanceFeeSourceReadRepository {
return mapper.list(filters).stream().map(this::summary).toList(); 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) { public List<OperationLog> logs(long customerId, long id) {
List<OperationLog> result = new ArrayList<>(); 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.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.EventTypes;
import com.baiyue.finance.repository.BusinessFeeSourceRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -13,11 +14,13 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
/** Consumes complete overseas business fee snapshots without a cross-region synchronous callback. */ /** 接收海外费用不可变快照,在同一事务内完成来源版本与接收记录。 */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class BusinessFeeSourceEventService { public class BusinessFeeSourceEventService {
@@ -26,7 +29,9 @@ public class BusinessFeeSourceEventService {
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final FinanceSupplierValidator supplierValidator; private final FinanceSupplierValidator supplierValidator;
private final BusinessFeeSourceRepository sources;
/** 先核对不可变事件,再恢复已提交结果;仅新事件检查外部主档。 */
@Transactional @Transactional
public DeliveryAck consume(EventEnvelope envelope) { public DeliveryAck consume(EventEnvelope envelope) {
try { try {
@@ -56,6 +61,8 @@ public class BusinessFeeSourceEventService {
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败"); 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')", 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()); envelope.eventId(), sourceSystem, envelope.eventType(), eventJson, envelope.payloadHash());
long result = apply(sourceSystem, source); long result = apply(sourceSystem, source);
@@ -64,6 +71,7 @@ public class BusinessFeeSourceEventService {
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
} }
/** 锁定同一来源的历史版本,拒绝跨客户替代,并保留已出账来源冻结规则。 */
private long apply(String system, BusinessFeeSourceChangedV1 source) { private long apply(String system, BusinessFeeSourceChangedV1 source) {
long feeId = positive(source.sourceFeeId(), "sourceFeeId"); long feeId = positive(source.sourceFeeId(), "sourceFeeId");
List<Map<String, Object>> same = jdbc.queryForList( List<Map<String, Object>> same = jdbc.queryForList(
@@ -83,10 +91,12 @@ public class BusinessFeeSourceEventService {
} }
if ("VOIDED".equals(source.action())) if ("VOIDED".equals(source.action()))
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
List<Map<String, Object>> versions = jdbc.queryForList( var versions = sources.lockVersions(system, source.sourceType(), feeId);
"SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE", long customerId = positive(source.customerId(), "customerId");
system, source.sourceType(), feeId); // 来源身份跨版本保持客户归属;检查失败时 Inbox 插入也随当前事务回滚。
if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion())) 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", "费用来源版本已过期"); throw error("FIN-EVT-008", "费用来源版本已过期");
requireSourceNotFrozen(system, source.sourceType(), feeId); 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'", 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; return id;
} }
/** 校验载荷自身的身份、金额和摘要,不依赖会随时间变化的外部数据。 */
private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) { private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) {
if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem()) if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem())
|| "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType()) || "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType())
@@ -130,13 +141,17 @@ public class BusinessFeeSourceEventService {
} }
positive(value.sourceFeeId(), "sourceFeeId"); positive(value.sourceFeeId(), "sourceFeeId");
positive(value.customerId(), "customerId"); positive(value.customerId(), "customerId");
validateAmounts(value);
if (value.walletSettlement() != null) { if (value.walletSettlement() != null) {
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用"); if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
positive(value.walletSettlement().holdId(), "walletSettlement.holdId"); positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId"); positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用"); if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
walletIdentity(value); walletIdentity(value);
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) { } else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())
// 新采购主单复用人工收费,只有显式账单结算可入池;旧采购和余额费用仍必须引用实扣。
&& !("LASTLEG".equals(value.sourceSystem()) && "OVERSEAS_PROCUREMENT".equals(value.businessType())
&& "BILL".equals(value.settlementMode()))) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池"); throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
} }
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) { if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
@@ -149,10 +164,8 @@ public class BusinessFeeSourceEventService {
throw error("FIN-EVT-015", "费用来源尚未发布或已失效"); throw error("FIN-EVT-015", "费用来源尚未发布或已失效");
} }
if ("PAYABLE".equals(value.direction())) { if ("PAYABLE".equals(value.direction())) {
long supplierId = positive(value.supplierId(), "supplierId"); positive(value.supplierId(), "supplierId");
var supplier = supplierValidator.requireActive(supplierId, trim(value.supplierCategory())); if (blank(value.supplierName())) throw error("FIN-EVT-012", "应付来源供应商快照不完整");
if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name()))
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
validateSettlement(value); validateSettlement(value);
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory()) } else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|| value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null || value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null
@@ -174,6 +187,31 @@ public class BusinessFeeSourceEventService {
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); 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) { private void validateSettlement(BusinessFeeSourceChangedV1 value) {
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if ("ON_ACCOUNT".equals(value.settlementMode())) { if ("ON_ACCOUNT".equals(value.settlementMode())) {
@@ -244,8 +282,7 @@ public class BusinessFeeSourceEventService {
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem())) if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
throw error("FIN-EVT-017", "资金业务引用不完整"); throw error("FIN-EVT-017", "资金业务引用不完整");
if ("OVERSEAS_MANUAL_FEE".equals(type)) { if ("OVERSEAS_MANUAL_FEE".equals(type)) {
if ("FEE_TYPE_077".equals(source.feeItemCode()) if (!no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配"); throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
} else if ("STORAGE_RENT".equals(type)) { } else if ("STORAGE_RENT".equals(type)) {
if (!"FEE_TYPE_077".equals(source.feeItemCode())) if (!"FEE_TYPE_077".equals(source.feeItemCode()))
@@ -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.ExportColumn;
import com.baiyue.common.core.export.ExportContracts.ExportColumnType; import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; 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.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; 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.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -199,41 +198,16 @@ public class FinanceExportProviderService {
.map(value -> receivableStatisticsRow(month, value)).toList(); .map(value -> receivableStatisticsRow(month, value)).toList();
} }
/** 导出与列表使用相同当前版本、状态及筛选,已付款来源不能被可选结算池遗漏。 */
private List<Map<String, Object>> feeRows(Map<String, Object> p) { 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<>(); List<Map<String, Object>> rows = new ArrayList<>();
for (FeePoolItem item : queryService.feePool(customerId, direction)) { for (int page = 1; ; page++) {
FeeSourceDetail detail = detail(item); var result = operationService.listFinanceFeeSources(page, 200, longValue(p, "customerId", false),
String sourceStatus = detail == null ? item.status() : detail.sourceStatus(); text(p, "direction"), text(p, "status"), text(p, "reviewStatus"), bool(p, "statementGenerated"),
String actualReview = detail == null ? null : detail.reviewStatus(); date(p, "businessDateFrom"), date(p, "businessDateTo"), text(p, "keyword"));
Boolean actualGenerated = detail == null ? null : detail.statementGenerated(); result.data().forEach(source -> rows.add(feeRow(source)));
LocalDate businessDate = detail == null || detail.businessDate() == null if ((long) page * 200 >= result.page().total()) return rows;
? (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));
} }
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) { 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(), "documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark()); "outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
} }
private Map<String, Object> feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) { private Map<String, Object> feeRow(FeeSourceSummary source) {
return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(), return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(),
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(), "sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(), "containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(), "quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(),
"reviewStatus", detail == null ? null : detail.reviewStatus(), "reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated());
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
} }
private Map<String, Object> statementRow(ReconciliationDetail value) { private Map<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(), return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
@@ -390,17 +363,30 @@ public class FinanceExportProviderService {
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(), "receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(),
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt()); "remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
} }
/** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */
private Map<String, Object> settlementRow(SettlementSummary value) { private Map<String, Object> settlementRow(SettlementSummary value) {
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(), return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(), "currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", value.status()); "businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", settlementStatusLabel(value.status()));
} }
/** 明细导出与列表保持相同的中文结算状态。 */
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) { private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(), return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(),
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(), "feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(), "containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(), "lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", summary.status()); "outstandingAmount", summary.outstandingAmount(), "status", settlementStatusLabel(summary.status()));
}
/** 转换正式结算状态,未知值保留原值以便排查。 */
private String settlementStatusLabel(String status) {
if (status == null) return null;
return switch (status) {
case "ISSUED" -> "已出账";
case "PARTIALLY_PAID" -> "部分结清";
case "PAID" -> "已结清";
case "VOIDED" -> "已作废";
default -> status;
};
} }
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) { private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(), return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
@@ -178,7 +178,7 @@ public class FinancePhase1OperationService {
String normalizedReview = normalized(reviewStatus, String normalizedReview = normalized(reviewStatus,
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效"); List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus, 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, Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize); normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters)); return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
@@ -186,9 +186,7 @@ public class FinancePhase1OperationService {
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) { public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效"); 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); return feeSources.detail(feeSourceId);
if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在");
return rows.get(0);
} }
@Transactional @Transactional
@@ -225,6 +223,7 @@ public class FinancePhase1OperationService {
adjustment ? "POST" : "PUT", path, request); adjustment ? "POST" : "PUT", path, request);
if (replay != null) return getFinanceFeeSource(replay); if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整"); if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可编辑或调整");
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus(); String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) { if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑"); throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
@@ -302,6 +301,7 @@ public class FinancePhase1OperationService {
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request); "/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
if (replay != null) return getFinanceFeeSource(replay); if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作"); 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", "费用来源当前状态不允许该操作"); if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
int changed = voidSource 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=?", ? 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; 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) { 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"); 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)); 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 static final String CNY = "CNY";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper; 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.jdbc = jdbc;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.customerNames = customerNames;
} }
public CustomerAccount customerAccount(long customerId) { public CustomerAccount customerAccount(long customerId) {
@@ -131,12 +133,13 @@ public class FinanceQueryService {
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) { public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId); validateOptionalCustomer(customerId);
var names = customerNames.resolver();
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : ""; 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"; 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]; Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; } if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC", 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.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.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"), rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
@@ -147,6 +150,7 @@ public class FinanceQueryService {
public List<ReconciliationSummary> reconciliationStatements(Long customerId) { public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
validateOptionalCustomer(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," + 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," + "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," + "COALESCE(p.credit_days,30) credit_days," +
@@ -163,7 +167,7 @@ public class FinanceQueryService {
Object[] args = new Object[0]; Object[] args = new Object[0];
if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; } 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( 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"), 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.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"), 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) { public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId); requireCustomer(customerId);
var names = customerNames.resolver();
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : ""; 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, 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.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.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"), 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(); row.outstandingAmount(), row.currency(), row.status(), row.version())).toList();
} }
/** 成本查询按来源付款事实回显金额,直接已付不能再次展示为待付款。 */
public List<PayableSettlement> payableSettlements(Long customerId) { public List<PayableSettlement> payableSettlements(Long customerId) {
validateOptionalCustomer(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"; 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", return jdbc.query(sql + " ORDER BY created_at DESC,id DESC",
(rs, rowNum) -> { (rs, rowNum) -> {
BigDecimal amount = rs.getBigDecimal("cost_amount"); 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"), 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"), 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")); time(rs, "created_at"), rs.getInt("version"));
}, args); }, args);
} }
@@ -52,19 +52,24 @@ public class FinanceSettlementService {
private final FinanceVoucherValidator voucherValidator; private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator; private final FinanceSupplierValidator supplierValidator;
private final FinanceDocumentNumberGenerator documentNumbers; private final FinanceDocumentNumberGenerator documentNumbers;
private final FinanceCustomerNames customerNames;
private final com.baiyue.finance.repository.FinanceSettlementStateRepository states;
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
@Autowired @Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator, FinanceSupplierValidator supplierValidator,
FinanceDocumentNumberGenerator documentNumbers, 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.jdbc = jdbc;
this.details = details; this.details = details;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator; this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator; this.supplierValidator = supplierValidator;
this.documentNumbers = documentNumbers; this.documentNumbers = documentNumbers;
this.customerNames = customerNames;
this.states = states;
} }
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */ /** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
@@ -87,18 +92,16 @@ public class FinanceSettlementService {
Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST", Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST",
"/api/platform/finance/v1/receivable-settlements", request); "/api/platform/finance/v1/receivable-settlements", request);
if (replay != null) return getSettlement(replay); if (replay != null) return getSettlement(replay);
Integer occupied = jdbc.queryForObject( // 新建与重开共用当前锁定读,等待来源锁后不能沿用事务开始时的占用快照。
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' " + if (states.billOccupied(customerId, request.reconciliationStatementId(), 0))
"AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class, throw error("FIN_409", "对账单已生成有效应收结算单");
customerId, request.reconciliationStatementId());
if (occupied != null && occupied > 0) throw error("FIN_409", "对账单已生成有效应收结算单");
Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?", Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",
Integer.class, customerId, request.reconciliationStatementId()); Integer.class, customerId, request.reconciliationStatementId());
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细"); if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
LocalDate generatedDate = LocalDate.now(); LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE", 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, date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
generatedDate, date(bill, "due_date"), operatorId); generatedDate, date(bill, "due_date"), operatorId);
int inserted = jdbc.update( int inserted = jdbc.update(
@@ -146,17 +149,14 @@ public class FinanceSettlementService {
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用"); throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
} }
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY"); if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
Integer used = jdbc.queryForObject( if (states.sourceOccupied(customerId, number(source, "id").longValue(), 0))
"SELECT COUNT(*) FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id " + throw error("FIN_409", "费用来源已被有效结算单占用");
"WHERE sl.fee_source_id=? AND s.status<>'VOIDED'", Integer.class, number(source, "id").longValue());
if (used != null && used > 0) throw error("FIN_409", "费用来源已被有效结算单占用");
} }
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0"); if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
String supplierCategory = supplierCategoryConstraint(sources); String supplierCategory = supplierCategoryConstraint(sources);
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name(); String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name();
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName")) String customerName = customerNames.required(customerId);
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
LocalDate generatedDate = LocalDate.now(); LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName, long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")), request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
@@ -176,7 +176,7 @@ public class FinanceSettlementService {
if (settlementId <= 0) throw error("FIN_404", "结算单不存在"); if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
List<Map<String, Object>> records = details.summary(settlementId); List<Map<String, Object>> records = details.summary(settlementId);
if (records.isEmpty()) throw error("FIN_404", "结算单不存在"); 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) List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList(); .stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId) List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
@@ -206,7 +206,8 @@ public class FinanceSettlementService {
args.add(customerId); args.add(customerId);
} }
sql += " ORDER BY s.generated_date DESC,s.id DESC"; 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 @Transactional
@@ -247,13 +248,14 @@ public class FinanceSettlementService {
return getSettlement(settlementId); return getSettlement(settlementId);
} }
/** 作废保留来源关联;重开必须重新校验并锁定当前仍可占用的原始依据。 */
@Transactional @Transactional
public SettlementDetail actionSettlement(long settlementId, long operatorId, String key, public SettlementDetail actionSettlement(long settlementId, long operatorId, String key,
SettlementActionRequest request) { SettlementActionRequest request) {
validateSettlementAction(request); validateSettlementAction(request);
Map<String, Object> settlement = one( List<Map<String, Object>> locked = states.lockSettlement(settlementId);
"SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " + if (locked.isEmpty()) throw error("FIN_404", "结算单不存在");
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId); Map<String, Object> settlement = locked.get(0);
long customerId = number(settlement, "tenant_id").longValue(); long customerId = number(settlement, "tenant_id").longValue();
Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST", Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST",
"/api/platform/finance/v1/settlements/" + settlementId + "/actions", request); "/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) { if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开"); throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开");
} }
requireReopenableSources(settlement);
targetStatus = "ISSUED"; targetStatus = "ISSUED";
} }
int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " + int changed = states.changeStatus(targetStatus, operatorId, settlementId, customerId, request.expectedVersion());
"WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId,
request.expectedVersion());
if (changed != 1) throw error("FIN_409", "结算单版本冲突"); if (changed != 1) throw error("FIN_409", "结算单版本冲突");
audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement), audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement),
settlementStateSnapshot(settlement, targetStatus), operatorId); settlementStateSnapshot(settlement, targetStatus), operatorId);
@@ -284,6 +285,77 @@ public class FinanceSettlementService {
return getSettlement(settlementId); 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, private long insertSettlement(long customerId, String no, String direction, String customerName,
Long supplierId, String supplierName, Long reconciliationId, Long supplierId, String supplierName, Long reconciliationId,
String businessPeriod, String currency, BigDecimal total, String businessPeriod, String currency, BigDecimal total,
@@ -419,9 +491,9 @@ public class FinanceSettlementService {
MDC.get("requestId"), MDC.get("traceId")); 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"), 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, "supplier_id"), text(row, "supplier_name_snapshot"),
nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"), nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"),
text(row, "business_period"), text(row, "fee_item_summary"), text(row, "business_period"), text(row, "fee_item_summary"),
@@ -15,9 +15,11 @@ import java.util.List;
public class ReceivableStatisticsService { public class ReceivableStatisticsService {
private static final String CURRENCY = "CNY"; private static final String CURRENCY = "CNY";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final FinanceCustomerNames customerNames;
public ReceivableStatisticsService(JdbcTemplate jdbc) { public ReceivableStatisticsService(JdbcTemplate jdbc, FinanceCustomerNames customerNames) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.customerNames = customerNames;
} }
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) { public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
@@ -61,8 +63,9 @@ public class ReceivableStatisticsService {
args.add(customerId); args.add(customerId);
} }
sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id"); 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( 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_receivable"), rs.getBigDecimal("total_received"),
rs.getBigDecimal("total_outstanding"))), args.toArray()); rs.getBigDecimal("total_outstanding"))), args.toArray());
} }
@@ -46,14 +46,16 @@ public class ReconciliationStatementService {
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final FinanceDocumentNumberGenerator documentNumbers; private final FinanceDocumentNumberGenerator documentNumbers;
private final FinanceCustomerNames customerNames;
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
@Autowired @Autowired
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper, public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceDocumentNumberGenerator documentNumbers) { FinanceDocumentNumberGenerator documentNumbers, FinanceCustomerNames customerNames) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.documentNumbers = documentNumbers; this.documentNumbers = documentNumbers;
this.customerNames = customerNames;
} }
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */ /** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
@@ -112,6 +114,7 @@ public class ReconciliationStatementService {
return detail(request.customerId(), statementId); return detail(request.customerId(), statementId);
} }
/** 推进对账单状态并保存修订,响应包含本次操作刚写入的审计记录。 */
@Transactional @Transactional
public ReconciliationDetail action(long operatorId, long statementId, String key, ReconciliationActionRequest request) { public ReconciliationDetail action(long operatorId, long statementId, String key, ReconciliationActionRequest request) {
validateAction(statementId, request); validateAction(statementId, request);
@@ -150,9 +153,11 @@ public class ReconciliationStatementService {
ReconciliationDetail after = detail(before.customerId(), statementId); ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId); audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId); complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId);
return after; // 审计在金额/状态快照之后写入,返回前重新读取,避免首个响应缺少最新修订。
return detail(before.customerId(), statementId);
} }
/** 调整已出账明细并保留前后金额,响应与随后查询的修订历史一致。 */
private ReconciliationDetail adjust(long operatorId, long statementId, String key, private ReconciliationDetail adjust(long operatorId, long statementId, String key,
ReconciliationActionRequest request, Bill before) { ReconciliationActionRequest request, Bill before) {
if (!"ISSUED".equals(before.status())) throw error("FIN_409", "仅已出账对账单允许调整"); if (!"ISSUED".equals(before.status())) throw error("FIN_409", "仅已出账对账单允许调整");
@@ -174,7 +179,7 @@ public class ReconciliationStatementService {
ReconciliationDetail after = detail(before.customerId(), statementId); ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId); audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId);
complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId); complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId);
return after; return detail(before.customerId(), statementId);
} }
public ReconciliationDetail detail(long customerId, long statementId) { public ReconciliationDetail detail(long customerId, long statementId) {
@@ -195,7 +200,7 @@ public class ReconciliationStatementService {
List<ReconciliationRevision> revisions = jdbc.query( 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", "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); (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(), metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(), bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
@@ -278,7 +283,7 @@ public class ReconciliationStatementService {
private CustomerProfile customer(long customerId) { 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'", 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); (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() { private long activeSubject() {
@@ -488,18 +493,19 @@ public class ReconciliationStatementService {
} }
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException { private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")), return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data"), "total"),
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"), amountFromJson(rs.getString("after_data"), "totalReceivable"), rs.getString("reason"), rs.getLong("operator_id"),
rs.getString("operator_name_snapshot"), rs.getString("operator_name_snapshot"),
rs.getTimestamp("created_at").toLocalDateTime()); rs.getTimestamp("created_at").toLocalDateTime());
} }
private BigDecimal amountFromJson(String value) { /** 变更前为 Bill,变更后为 ReconciliationDetail;缺失金额属于损坏快照,不能伪装为零。 */
if (!StringUtils.hasText(value)) return BigDecimal.ZERO; private BigDecimal amountFromJson(String value, String field) {
try { try {
JsonNode node = objectMapper.readTree(value); JsonNode node = objectMapper.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(value);
JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total"); JsonNode amount = node.get(field);
return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue(); if (amount == null || !amount.isNumber()) throw new IllegalArgumentException("缺失金额");
return amount.decimalValue();
} catch (Exception exception) { } catch (Exception exception) {
throw error("FIN_001", "对账单修订快照损坏"); throw error("FIN_001", "对账单修订快照损坏");
} }
+4 -2
View File
@@ -225,7 +225,8 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version), UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
KEY idx_fin_bill_line_source (source_type, source_id, source_version),
KEY idx_fin_bill_line_bill (tenant_id, bill_id), KEY idx_fin_bill_line_bill (tenant_id, bill_id),
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0), CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
@@ -437,6 +438,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
due_date DATE NOT NULL COMMENT '结算到期日期', due_date DATE NOT NULL COMMENT '结算到期日期',
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', 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已作废', 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 '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
@@ -444,7 +446,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no), 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_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_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), 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.common.event.EventTypes;
import com.baiyue.finance.service.BusinessFeeSourceEventService; import com.baiyue.finance.service.BusinessFeeSourceEventService;
import com.baiyue.finance.service.FinanceSupplierValidator; 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; 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.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate; 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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -37,7 +45,8 @@ class BusinessFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class); private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class); 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 @Test
void importsOverseasFeeWithoutInventingInboundReference() { void importsOverseasFeeWithoutInventingInboundReference() {
@@ -116,19 +125,93 @@ class BusinessFeeSourceEventServiceTest {
verify(jdbc, never()).update(anyString(), any(Object[].class)); verify(jdbc, never()).update(anyString(), any(Object[].class));
} }
/** 旧版本不能覆盖已有较新版本,客户相同时仍保留原乱序门禁。 */
@Test @Test
void lateOlderVersionCannotSupersedeNewerSourceOrCost() { void lateOlderVersionCannotSupersedeNewerSourceOrCost() {
when(suppliers.requireActive(71L, null)).thenReturn( when(suppliers.requireActive(71L, null)).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"), when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L))
eq("OVERSEAS_CONTAINER_FEE"), eq(701L))) .thenReturn(List.of(new BusinessFeeSourceVersion(2, 10001L)));
.thenReturn(List.of(Map.of("source_fee_version", 2)));
assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1))) assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1)))
.hasMessageContaining("版本已过期"); .hasMessageContaining("版本已过期");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class)); verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), 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 @Test
void replayReturnsOriginalSourceWithoutDuplicatingCost() { void replayReturnsOriginalSourceWithoutDuplicatingCost() {
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
@@ -245,6 +328,53 @@ class BusinessFeeSourceEventServiceTest {
noFinancialWrites(); noFinancialWrites();
} }
/** 新采购主单只有明确选择账单时才可作为未收应收入池。 */
@Test
void manualProcurementExplicitBillCanEnterReceivablePool() {
EventEnvelope event = manualProcurementEvent("BILL");
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
}
/** 采购主单的余额或默认模式不能因兼容账单而绕过实扣校验。 */
@ParameterizedTest
@org.junit.jupiter.params.provider.NullSource
@ValueSource(strings = {"WALLET"})
void manualProcurementWithoutConsumptionCannotEnterPool(String mode) {
assertThatThrownBy(() -> service.consume(manualProcurementEvent(mode))).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
/** 保留旧采购子费用的扣款约束,即使载荷声明账单结算也不能入池。 */
@Test
void legacyProcurementCannotBypassConsumptionWithBillMode() {
EventEnvelope base = procurementEvent(false);
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("settlementMode", "BILL");
assertThatThrownBy(() -> service.consume(resignProcurementEvent(base, payload))).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
private EventEnvelope manualProcurementEvent(String mode) {
EventEnvelope base = procurementEvent(false);
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("businessType", "OVERSEAS_PROCUREMENT");
if (mode == null) payload.putNull("settlementMode"); else payload.put("settlementMode", mode);
return resignProcurementEvent(base, payload);
}
private EventEnvelope resignProcurementEvent(EventEnvelope base, ObjectNode payload) {
payload.putNull("action"); payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", "PUBLISHED");
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@Test @Test
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() { void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
EventEnvelope event = procurementEvent(true); EventEnvelope event = procurementEvent(true);
@@ -292,6 +422,12 @@ class BusinessFeeSourceEventServiceTest {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071"); verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
} }
/** 手动仓租保留人工费用资金身份,仍校验原冻结与实扣来源。 */
@Test
void manualStorageRentConsumptionUsesManualFundingIdentity() {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_077");
}
@Test @Test
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() { void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077"); verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
@@ -0,0 +1,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))) when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L)))
.thenReturn(List.of()); .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"); assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER");
var amountSql = org.mockito.ArgumentCaptor.forClass(String.class); 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"); 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 -> 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)); ((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); 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 -> 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)); ((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.totalReceivable()).isZero();
assertThat(result.receivedAmount()).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())) when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null)); .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); .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 @Test
void receivablePoolNeverContainsPayableSource() { void receivablePoolNeverContainsPayableSource() {
DirectionJdbc jdbc = new DirectionJdbc(); 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"); List<FeePoolItem> result = service.feePool(7L, "RECEIVABLE");
@@ -30,7 +30,7 @@ class FinanceFeePoolDirectionBehaviorTest {
@Test @Test
void payablePoolNeverContainsReceivableSource() { void payablePoolNeverContainsReceivableSource() {
DirectionJdbc jdbc = new DirectionJdbc(); 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"); List<FeePoolItem> result = service.feePool(7L, "PAYABLE");
@@ -42,7 +42,7 @@ class FinanceFeePoolDirectionBehaviorTest {
@Test @Test
void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() { void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() {
DirectionJdbc jdbc = new DirectionJdbc(); 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); service.feePool(7L, "PAYABLE", 81L);
@@ -15,6 +15,7 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -30,8 +31,11 @@ public class FinanceFeeSourceReadMapperTest {
try (var connection = data.getConnection()) { try (var connection = data.getConnection()) {
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'"); 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 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_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)"); 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)"); 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, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.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( assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE"); "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)"); 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); filters.put("status", "BILLED"); filters.put("statementGenerated", true);
session.clearCache();
assertThat(repository.count(filters)).isEqualTo(1); assertThat(repository.count(filters)).isEqualTo(1);
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L); 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"); insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
@@ -189,8 +189,8 @@ class FinancePhase1OperationBehaviorTest {
/** 保留费用审核状态读取。 */ /** 保留费用审核状态读取。 */
@Test @Test
void approvedFeeCannotBeEditedAsDraft() throws Exception { void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 2));
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED"); assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
} }
@@ -199,7 +199,7 @@ class FinancePhase1OperationBehaviorTest {
@Test @Test
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception { void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
FinancePhase1OperationService service = new FinancePhase1OperationService( 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); FeeSourceDetail detail = service.getFinanceFeeSource(88L);
@@ -212,8 +212,8 @@ class FinancePhase1OperationBehaviorTest {
/** 业务来源费用必须回到所属系统调整。 */ /** 业务来源费用必须回到所属系统调整。 */
@Test @Test
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception { void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1); JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 1));
var request = new FinancePhase1Dtos.FeeSourceEditRequest( var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1); new BigDecimal("120.00"), "更正金额", 1);
@@ -228,7 +228,7 @@ class FinancePhase1OperationBehaviorTest {
/** 头程作废等待权威来源事件,不能直接改财务副本。 */ /** 头程作废等待权威来源事件,不能直接改财务副本。 */
@Test @Test
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception { 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), when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of()); eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of());
FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class); FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
@@ -236,7 +236,7 @@ class FinancePhase1OperationBehaviorTest {
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult( .thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
"INBOUND_FEE", 700L, 3, "VOIDED", false))); "INBOUND_FEE", 700L, 3, "VOIDED", false)));
FinancePhase1OperationService service = new FinancePhase1OperationService( 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", FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2)); "void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
@@ -262,13 +262,9 @@ class FinancePhase1OperationBehaviorTest {
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
AtomicInteger reads = new AtomicInteger(); AtomicInteger reads = new AtomicInteger();
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { var sources = mock(FinanceFeeSourceReadRepository.class);
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1); when(sources.detail(88L)).thenAnswer(invocation -> reads.getAndIncrement() == 0
String review = reads.getAndIncrement() == 0 ? "PENDING" : "APPROVED"; ? feeDetail("PENDING", false, 0) : feeDetail("APPROVED", false, 1));
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);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of()); eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), 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); eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), 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); 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", FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
@@ -332,45 +328,30 @@ class FinancePhase1OperationBehaviorTest {
.when(service).getFinanceFeeSource(88L); .when(service).getFinanceFeeSource(88L);
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true)) assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据"); .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 的审计查询。 */ /** 服务行为测试由 Repository 返回明确快照;正式 SQL 与 DTO 映射由 Mapper 数据库测试覆盖。 */
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception { private static FinanceFeeSourceReadRepository feeSources(String review, boolean generated, int version) throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class); var repository = mock(FinanceFeeSourceReadRepository.class);
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { when(repository.detail(88L)).thenReturn(feeDetail(review, generated, version));
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1); return repository;
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;
} }
private static ResultSet resultSet(String reviewStatus, boolean included, int version) throws Exception { private static FeeSourceDetail feeDetail(String review, boolean generated, int version) throws Exception {
ResultSet rs = mock(ResultSet.class); var node = objectMapper().createObjectNode().put("feeSourceId",88).put("sourceSystem","FIRSTLEG")
when(rs.getLong("id")).thenReturn(88L); .put("sourceType","INBOUND_FEE").put("sourceFeeId",700).put("sourceVersion",3).put("customerId",9)
when(rs.getLong("source_fee_id")).thenReturn(700L); .put("direction","RECEIVABLE").put("orderId",90).put("inboundId",100).put("containerId",200)
when(rs.getLong("customer_id")).thenReturn(9L); .put("inboundCode","NYEXX2608180003").put("sourceBusinessId","IN-100").put("feeItemName","头程运费")
when(rs.getLong("order_id")).thenReturn(90L); .put("amount",100).put("currency","CNY").put("operationVersion",version).put("reviewStatus",review)
when(rs.getLong("inbound_id")).thenReturn(100L); .put("sourceStatus",generated ? "BILLED" : "AVAILABLE").put("statementGenerated",generated)
when(rs.getLong("container_id")).thenReturn(200L); .put("sourceHash","a".repeat(64)).put("businessDate","2026-08-11");
when(rs.wasNull()).thenReturn(false, false, true, false, false, true); return objectMapper().treeToValue(node,FeeSourceDetail.class);
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 ObjectMapper objectMapper() { private static ObjectMapper objectMapper() {
@@ -16,7 +16,7 @@ class FinancePlatformOptionalCustomerQueryTest {
@Test @Test
void allCustomerQueriesOmitCustomerPredicateAndArguments() { void allCustomerQueriesOmitCustomerPredicateAndArguments() {
RecordingJdbc jdbc = new RecordingJdbc(); 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.feePool(null, null);
service.statements(null, false); service.statements(null, false);
@@ -33,7 +33,7 @@ class FinancePlatformOptionalCustomerQueryTest {
@Test @Test
void customerFilterAddsPredicateAndRealCustomerArgument() { void customerFilterAddsPredicateAndRealCustomerArgument() {
RecordingJdbc jdbc = new RecordingJdbc(); 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.feePool(31L, null);
service.statements(31L, false); service.statements(31L, false);
@@ -49,7 +49,7 @@ class FinancePlatformOptionalCustomerQueryTest {
@Test @Test
void optionalCustomerNeverTreatsZeroAsAllCustomers() { 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.statements(0L, false)).hasMessageContaining("客户范围无效");
assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效"); assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效");
@@ -0,0 +1,66 @@
package com.baiyue.finance;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FinanceReconciliationSchemaContractTest {
/** 执行真实明细表约束,复现两笔费用作废后重新出单,保留原单快照且单内不能重复。 */
@ParameterizedTest
@ValueSource(booleans = {false, true})
void voidedStatementKeepsHistoryWhileItsTwoSourcesCanEnterANewStatement(boolean unified) throws Exception {
JdbcTemplate jdbc = createSchema(unified);
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT NOT NULL,status VARCHAR(32) NOT NULL)");
jdbc.update("INSERT INTO fin_bill VALUES(501,9,'ISSUED'),(502,9,'DRAFT')");
insertLine(jdbc, 1, 501, 88);
insertLine(jdbc, 2, 501, 89);
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=501");
insertLine(jdbc, 3, 502, 88);
insertLine(jdbc, 4, 502, 89);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=501", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=502", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT status FROM fin_bill WHERE id=501", String.class)).isEqualTo("VOIDED");
assertThat(jdbc.queryForObject("SELECT SUM(l.amount) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id WHERE b.status<>'VOIDED'", BigDecimal.class))
.isEqualByComparingTo("200.00");
assertThatThrownBy(() -> insertLine(jdbc, 5, 502, 88)).isInstanceOf(DuplicateKeyException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line", Integer.class)).isEqualTo(4);
}
/** 从模块或统一空库入口读取真实建表语句,仅移除 H2 不支持的 MySQL 存储语法。 */
private static JdbcTemplate createSchema(boolean unified) throws Exception {
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
if (!Files.exists(unifiedPath)) unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String sql = Files.readString(unified ? unifiedPath : Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_bill_line \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql);
assertThat(table.find()).isTrue();
String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");")
.replaceAll("(?i) COMMENT '(?:[^']|'')*'", "")
.replaceAll("(?i)\\bUNSIGNED\\b|_utf8mb4", "")
.replaceAll("(?m)^\\s*KEY [^\\r\\n]+\\r?\\n", "")
.replaceAll("UNIQUE KEY (\\w+)", "CONSTRAINT $1 UNIQUE");
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"));
jdbc.execute(ddl);
return jdbc;
}
/** 写入完整合成明细,仅改变本测试关注的单据和费用来源身份。 */
private static void insertLine(JdbcTemplate jdbc, long id, long billId, long sourceId) {
jdbc.update("INSERT INTO fin_bill_line(id,tenant_id,bill_id,source_type,source_id,source_version,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,9,?,'FIRSTLEG_FEE',?,1,'SYNTHETIC','合成测试费用',100,'CNY')",
id, billId, sourceId);
}
}
@@ -20,6 +20,8 @@ import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator; import com.baiyue.finance.service.FinanceVoucherValidator;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; 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.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
@@ -100,6 +102,7 @@ class FinanceSettlementBehaviorTest {
assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L); assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L);
assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L); assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L);
assertThat(scoped).singleElement().extracting(SettlementSummary::customerName).isEqualTo("测试客户公司");
ArgumentCaptor<String> allSql = ArgumentCaptor.forClass(String.class); ArgumentCaptor<String> allSql = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class); ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE")); 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()); 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 @Test
void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() { void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> 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("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"))))); 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.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); 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)); 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("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"))))); 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.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", assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createReceivableSettlement(7L, "ar-duplicate",
new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409"); new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409");
@@ -370,7 +510,7 @@ class FinanceSettlementBehaviorTest {
new BigDecimal("100.00"), 0)); 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 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.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(); FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), 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"); 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 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)); 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))) 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) ? 1 : 0); .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, 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"); 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("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"))))); 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.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)); FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createReceivableSettlement(7L, "ar-key", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409"); 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); 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 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.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)); FinanceSettlementService occupiedService = service(occupiedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> occupiedService.createPayableSettlement(7L, "occupied-key", request), "FIN_409"); 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]+\\}", "?"); String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
return jdbc.queryForList(sql, invocation.getArguments()); 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(), 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() { private static FinanceSupplierValidator supplierValidator() {
@@ -1,12 +1,20 @@
package com.baiyue.finance; package com.baiyue.finance;
import org.junit.jupiter.api.Test; 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.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FinanceSettlementSchemaContractTest { class FinanceSettlementSchemaContractTest {
@@ -31,6 +39,51 @@ class FinanceSettlementSchemaContractTest {
assertThat(unified).doesNotContain("ALTER TABLE fin_settlement", "assert_finance"); 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() { private static Path resolveUnifiedSql() {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(direct) ? 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(tableCount).isEqualTo(23);
assertThat(columnCount).isEqualTo(352); assertThat(columnCount).isEqualTo(353);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
} }
@@ -28,7 +28,7 @@ class ReceivableStatisticsServiceTest {
row(10001, "客户A", "300.00", "100.00", "200.00"), row(10001, "客户A", "300.00", "100.00", "200.00"),
row(10002, "客户B", "80.00", "80.00", "0.00")); row(10002, "客户B", "80.00", "80.00", "0.00"));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows); 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); 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))) when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99"))); .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)) .query(YearMonth.of(2026, 7), null, 1, 20))
.isInstanceOf(FinanceEventException.class) .isInstanceOf(FinanceEventException.class)
.hasMessageContaining("勾稽异常"); .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) { private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) {
return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total), return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total),
new BigDecimal(received), new BigDecimal(outstanding)); new BigDecimal(received), new BigDecimal(outstanding));
@@ -11,6 +11,8 @@ import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.ReconciliationStatementService; import com.baiyue.finance.service.ReconciliationStatementService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; 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.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -83,6 +85,8 @@ class ReconciliationStatementBehaviorTest {
@Test @Test
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception { void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
JdbcTemplate jdbc = createJdbc(); 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); ReconciliationStatementService target = serviceWithNumber(jdbc);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(), CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账"); LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
@@ -90,7 +94,15 @@ class ReconciliationStatementBehaviorTest {
var result = target.create(71L, "cutoff-key", request); var result = target.create(71L, "cutoff-key", request);
assertThat(result.status()).isEqualTo("DRAFT"); assertThat(result.status()).isEqualTo("DRAFT");
assertThat(result.customerName()).isEqualTo("测试客户公司");
assertThat(result.totalReceivable()).isEqualByComparingTo("100.00"); 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), 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))); eq(9L), eq(LocalDate.of(2026, 8, 1)), eq(LocalDate.of(2026, 8, 31)));
} }
@@ -114,6 +126,22 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)); verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
} }
/** 已被占用的费用不能完成第二张有效对账单的生成,事务必须拒绝提交。 */
@Test
void createRejectsSourceWhoseAvailableStatusCannotBeLocked() throws Exception {
JdbcTemplate jdbc = createJdbc();
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)))
.thenReturn(0);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
assertThatThrownBy(() -> serviceWithNumber(jdbc).create(71L, "create-key", request))
.isInstanceOf(FinanceEventException.class)
.hasMessage("费用来源已被其他对账单占用")
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any());
}
@Test @Test
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception { void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
@@ -350,6 +378,29 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="), (Object[]) any()); 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 @Test
void voidIssuedStatementReleasesSourcesWhenNoReceiptOrSettlementExists() throws Exception { void voidIssuedStatementReleasesSourcesWhenNoReceiptOrSettlementExists() throws Exception {
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key"); JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
@@ -393,6 +444,24 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
} }
/** 作废单的两笔原费用有任一已进入新单时,旧单不得重开为另一张有效单。 */
@ParameterizedTest
@ValueSource(ints = {0, 1})
void reopenRejectsVoidedStatementWhenReplacementOccupiesAnySource(int reacquired) throws Exception {
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-conflict-key");
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L)))
.thenReturn(2);
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)))
.thenReturn(reacquired);
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "reopen-conflict-key",
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of())))
.isInstanceOf(FinanceEventException.class)
.hasMessage("原费用来源已被占用,无法重开对账单")
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any());
}
@Test @Test
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() { void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of()); ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
@@ -589,9 +658,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) { private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class); FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001"); 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());
} }
} }
@@ -45,6 +45,26 @@ class FinanceExportProviderServiceTest {
service.initializeSigningKey(); service.initializeSigningKey();
} }
@Test
/** 应付结算列表和详情导出均使用中文状态,包括无明细的单据。 */
void exportsSettlementStatusesInChinese() {
for (var entry : Map.of("ISSUED", "已出账", "PARTIALLY_PAID", "部分结清", "PAID", "已结清", "VOIDED", "已作废").entrySet()) {
var summary = new com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary(
701L, "AP-701", "PAYABLE", 10001L, "客户A", 301L, "供应商A", null, null,
"2026-11", "操作费", "CNY", BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN,
LocalDate.parse("2026-11-03"), null, "UNPAID", entry.getKey(), 7L, "财务员", null, null, null, 1);
when(settlementService.listSettlements("PAYABLE", null)).thenReturn(List.of(summary));
when(settlementService.getSettlement(701L)).thenReturn(new com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail(
summary, List.of(), List.of(), List.of()));
var list = service.exportBatch(FinanceExportProviderService.PAYABLE_SETTLEMENT_LIST,
request(Map.of("status", entry.getKey()), null, null));
var detail = service.exportBatch(FinanceExportProviderService.PAYABLE_SETTLEMENT_DETAIL,
request(Map.of("settlementId", "701"), null, null));
assertThat(list.rows().get(0)).containsEntry("status", entry.getValue());
assertThat(detail.rows().get(0)).containsEntry("status", entry.getValue());
}
}
@Test @Test
void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() { void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() {
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"), Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
@@ -85,6 +105,26 @@ class FinanceExportProviderServiceTest {
assertThat(continued.snapshotToken()).isEqualTo(first.snapshotToken()); 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 @Test
void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() { void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() {
assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,
+2 -2
View File
@@ -32,8 +32,8 @@
| 称重确认、整票库位块、部分拣货结算 | `service/MeasurementLocationOccupationService.java`、`service/MeasurementSubmissionService.java`;查“首张称重图片”“删除称重图片”“PDA 部分拣货” | | 称重确认、整票库位块、部分拣货结算 | `service/MeasurementLocationOccupationService.java`、`service/MeasurementSubmissionService.java`;查“首张称重图片”“删除称重图片”“PDA 部分拣货” |
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” | | 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” | | 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” | | FBA 计价、私人地址人工报价、客户价格簿与柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“私人地址”“客户专属价格簿、柜级阶梯运费与调价边界” |
| 费用项目、价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`pricebookquery/application/PriceBookQueryService.java`;查“价格簿查询内部切片” | | 费用项目、共享费用目录与价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`controller/InternalFeeCatalogController.java`、`pricebookquery/application/PriceBookQueryService.java`;共享目录契约 `src/main/resources/openapi/fee-catalog-internal-v1.yaml`,价格簿查“价格簿查询内部切片” |
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” | | 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” | | 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
| 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” | | 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” |
+4 -4
View File
@@ -30,8 +30,8 @@
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | | 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认 `ACTUAL` 不调整尺寸,可启用的调整算法为 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;私人地址 `PRIVATE` 使用 `/fees/manual-quote` 生成人工头程报价,按客户计费方式计算计费量且附加费为零,预览返回 `priceMatched=false`,报价后沿用费用版本、幂等与预装前初始费用校验;`POST /fees/preview` 与正式计价共用算法,只读返回当前选择值的计费量、参考单价和合计,不生成费用版本或改变履约、财务状态 |
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | | 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | | 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | | 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
@@ -47,7 +47,7 @@
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | | PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),国内 PRIVATE 按 OVERSEAS 交接并保留私人地址及箱码快照,同一入仓单的原始目的类型必须唯一;OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
@@ -76,7 +76,7 @@
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 - 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 - 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 - 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ACTUAL` 不调整尺寸,仓库修正值替换对应实测边;`ADD` 以修正后的实测尺寸为基准对三个维度分别增加命中箱规的尺寸值;`MULTIPLY` 使用选定的启用系数配置,未指定配置时取当前客户唯一默认配置,由服务端按规格档位选择系数并分别作用于长宽高,体积按系数三次方变化。设备与人工录入共用计算入口,行明确选择的算法优先,否则仅使用客户默认算法;预置或保存系数配置不等于启用该算法。测量行必须保存实际尺寸、选用规则和算法快照,修改确认箱数复用已保存单箱快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
@@ -37,6 +37,16 @@ import java.security.MessageDigest;
@RestController @RestController
@RequestMapping("/internal/firstleg/v1/inbounds") @RequestMapping("/internal/firstleg/v1/inbounds")
public class InternalInboundOrderController { public class InternalInboundOrderController {
/** 海外废弃溯源只读取已生成的有效箱唛,不触发打印或补建。 */
@org.springframework.web.bind.annotation.GetMapping("/{inboundId}/existing-carton-marks")
public ApiResponse<List<String>> existingCartonMarks(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@org.springframework.web.bind.annotation.PathVariable long inboundId,
@org.springframework.web.bind.annotation.RequestParam long customerId,
@org.springframework.web.bind.annotation.RequestParam String inboundCode) {
requireToken(token);
return ApiResponse.success(cartonLabels.existingCartonMarks(customerId, inboundId, inboundCode));
}
@Resource @Resource
private InboundOrderCreationService service; private InboundOrderCreationService service;
@Resource @Resource
@@ -7,6 +7,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
import com.baiyue.firstleg.service.FbaPricingService; import com.baiyue.firstleg.service.FbaPricingService;
@@ -34,6 +36,16 @@ public class PlatformFbaPricingController {
@Resource @Resource
private FbaPricingService service; private FbaPricingService service;
/** 预览当前重量、体积对应的计费量和价格,不生成费用版本或改变履约状态。 */
@PostMapping("/preview")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')")
public ApiResponse<FeePreviewResponse> preview(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @Valid @RequestBody FeePreviewRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.preview(scope, inboundId, request));
}
@PostMapping("/calculate") @PostMapping("/calculate")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')") @PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId", @BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
@@ -35,6 +35,13 @@ public interface ContainerCustomsMapper {
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy, f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
"""; """;
String CONTAINER_COLUMNS = """
id id,loading_plan_id loadingPlanId,container_booking_id containerBookingId,status status,
started_by startedBy,started_by_name startedByName,started_at startedAt,
completed_by completedBy,completed_by_name completedByName,completed_at completedAt,
broker_submitted_by brokerSubmittedBy,broker_submitted_by_name brokerSubmittedByName,
broker_submitted_at brokerSubmittedAt,created_at createdAt,updated_at updatedAt,version version
""";
@Select(""" @Select("""
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
@@ -132,19 +139,19 @@ public interface ContainerCustomsMapper {
int insertContainer(@Param("id") long id,@Param("planId") long planId, int insertContainer(@Param("id") long id,@Param("planId") long planId,
@Param("containerBookingId") long containerBookingId,@Param("status") String status); @Param("containerBookingId") long containerBookingId,@Param("status") String status);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId}")
ContainerRow container(@Param("planId") long planId); ContainerRow container(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id}")
ContainerRow containerById(@Param("id") long id); ContainerRow containerById(@Param("id") long id);
@Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId); ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
ContainerRow lockContainer(@Param("planId") long planId); ContainerRow lockContainer(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
ContainerRow lockContainerById(@Param("id") long id); ContainerRow lockContainerById(@Param("id") long id);
@Update(""" @Update("""
@@ -254,6 +254,9 @@ public class ContainerCustomsService {
throw conflict("当前柜尚未完成首次发送报关行资料"); throw conflict("当前柜尚未完成首次发送报关行资料");
} }
CompletionRow completion = mapper.lockCompletion(documentId); CompletionRow completion = mapper.lockCompletion(documentId);
if (completion != null && "CONFIRMED".equals(completion.customerConfirmStatus())) {
throw conflict("客户已确认预录单,不可再次上传");
}
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId; long completionId;
int version; int version;
@@ -76,6 +76,25 @@ public final class FbaPricingDtos {
} }
} }
/** 当前输入的只读预览,人工单价仅用于预览金额,不写入费用。 */
public record FeePreviewRequest(
LocalDateTime pricingAt,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@Size(max = 32) String weightSource,
@Size(max = 32) String volumeSource,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedUnitPrice
) {}
/** 当前选择值的只读计费预览,不包含费用版本或持久化标识。 */
public record FeePreviewResponse(
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal unitPrice, BigDecimal additionalAmount, BigDecimal totalAmount,
String currency, boolean priceMatched
) {}
public record ManualQuoteRequest( public record ManualQuoteRequest(
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.entity; package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter; import lombok.Getter;
@@ -34,6 +36,7 @@ public class InboundMeasurementItemEntity {
private BigDecimal volumeCbm; private BigDecimal volumeCbm;
private BigDecimal totalVolumeCbm; private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg; private BigDecimal volumeWeightKg;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long chargeRuleId; private Long chargeRuleId;
private String coefficientMode; private String coefficientMode;
private String calculationRuleVersion; private String calculationRuleVersion;
@@ -44,8 +47,12 @@ public class InboundMeasurementItemEntity {
private BigDecimal outerLengthCm; private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm; private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm; private BigDecimal outerHeightCm;
// 清空修正表示恢复对应实测边,必须显式写入 NULL,不能被默认的非空更新策略忽略。
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterLengthCm; private BigDecimal manualOuterLengthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterWidthCm; private BigDecimal manualOuterWidthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterHeightCm; private BigDecimal manualOuterHeightCm;
private String remark; private String remark;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -14,7 +14,7 @@ public interface CabinetLoadingEvidenceMapper extends BaseMapper<CabinetLoadingE
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id") @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id")
List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId); List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 LIMIT 1 FOR UPDATE") @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 ORDER BY id DESC LIMIT 1 FOR UPDATE")
CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId, CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("type") String type); @Param("type") String type);
@@ -74,8 +74,6 @@ public class CabinetLoadingEvidenceService {
if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试"); if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试");
if (current != null && Objects.equals(current.getFileId(), request.fileId()) if (current != null && Objects.equals(current.getFileId(), request.fileId())
&& Objects.equals(current.getSealNo(), sealNo)) return view(scope); && Objects.equals(current.getSealNo(), sealNo)) return view(scope);
if (current != null && mapper.voidActive(current.getId(), scope.containerBookingId(), current.getVersion(),
"REPLACED", operatorId, operatorName) != 1) throw conflict("装柜证据版本已变化,请刷新后重试");
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId); value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId);
value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName()); value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName());
@@ -84,8 +82,7 @@ public class CabinetLoadingEvidenceService {
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
try { mapper.insert(value); } catch (DuplicateKeyException exception) { try { mapper.insert(value); } catch (DuplicateKeyException exception) {
// A replacement may already have voided the old row in this transaction. // The task-scoped operation key protects concurrent duplicate uploads.
// Roll it back; a subsequent same-key request can read the committed winner.
throw conflict("装柜证据发生并发变更,请刷新后重试"); throw conflict("装柜证据发生并发变更,请刷新后重试");
} }
return view(scope); return view(scope);
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
String providerNodeCode, String standardNode, String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} String sourceReference, String rawPayloadHash, LocalDateTime createdAt,
String operatorName) {}
public record ContainerNodeDetail(Long containerBookingId, String currentNode, public record ContainerNodeDetail(Long containerBookingId, String currentNode,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {} LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
private String locationName; private String locationName;
private String sourceReference; private String sourceReference;
private String rawPayloadHash; private String rawPayloadHash;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
} }
@@ -11,14 +11,15 @@ import java.util.List;
@Mapper @Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> { public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
/** 保存不可变节点事实及执行该操作的账号快照。 */
@Insert(""" @Insert("""
INSERT INTO fl_carrier_schedule_node_fact INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at, (id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,operator_id,operator_name,created_at)
VALUES VALUES
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode}, (#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
CURRENT_TIMESTAMP(3)) CURRENT_TIMESTAMP(3))
""") """)
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value); int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
public long customerId(long id) { return required(id).getCustomerId(); } public long customerId(long id) { return required(id).getCustomerId(); }
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ /** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
@Transactional @Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId, public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId, long containerBookingId,
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
persistenceStage = "NODE_FACT"; persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId)); sha256("OUTBOUND_CONFIRM|" + containerBookingId), operatorId, operatorName);
if (departed != null) { if (departed != null) {
persistenceStage = "CROSS_REGION_OUTBOX"; persistenceStage = "CROSS_REGION_OUTBOX";
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), ""); + ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
} }
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
@Transactional @Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) { public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleQueryEntity value = requiredLocked(id);
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(), CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node, request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash()); request.locationName(), request.sourceReference(), request.rawPayloadHash(), null, null);
if (fact == null) { if (fact == null) {
return detail(value); return detail(value);
} }
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
return detail(value); return detail(value);
} }
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
@Transactional @Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key, public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) { ManualNodeRequest request) {
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node, CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(), request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request)); "MANUAL:" + operatorId, hash(operation, id, request), operatorId, operatorName);
if (fact == null) { if (fact == null) {
return complete(id, operatorId, key, detail(value)); return complete(id, operatorId, key, detail(value));
} }
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId)); return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
} }
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
@Transactional @Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId, public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key, String operatorName, String key,
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId, CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId, "MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash); request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash,
operatorId, operatorName);
if (fact != null && query != null) { if (fact != null && query != null) {
CarrierScheduleQueryEntity before = copy(query); CarrierScheduleQueryEntity before = copy(query);
requireManualActualAdvance(query, node, request.actualAt()); requireManualActualAdvance(query, node, request.actualAt());
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
repository.histories(value.getId()).stream().map(this::history).toList()); repository.histories(value.getId()).stream().map(this::history).toList());
} }
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
private NodeFact node(CarrierScheduleNodeFactEntity value) { private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(), return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt()); value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
} }
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) { private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
} }
/** 为查询任务追加节点事实并透传人工操作账号。 */
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId, private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId, return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName, providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash); sourceReference, rawPayloadHash, operatorId, operatorName);
} }
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId, private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode, String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId); fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
@@ -589,6 +596,7 @@ public class CarrierScheduleQueryService {
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
if (repository.insertIfAbsent(fact)) return fact; if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey()); CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) { if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
@@ -204,13 +204,15 @@ public final class SailingCalendarDtos {
int recommendedContainerCount, int pendingContainerCount, int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm,
BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt, BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {} BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm,
BigDecimal availableBookedCapacityCbm) {}
public record CalendarResult(String month, String timezone, List<CalendarItem> records, 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, public record Summary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, BigDecimal riskUnmatchedCbm,
int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount, int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int sailingCount, 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 FilterOptions(List<Option> warehouses, List<Option> channels, List<Option> departurePorts, List<Option> destinationPorts) {}
public record Option(String value, String label) {} public record Option(String value, String label) {}
} }

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