Compare commits

..
Author SHA1 Message Date
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 5b53be23e8 fix(finance): show direct-paid costs as fully paid 2026-10-26 16:45:36 +08:00
220 changed files with 6052 additions and 9061 deletions
@@ -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 隐式提交,不能宣称整体可回滚;失败保留错误并只读复查,已完成项不删除重建。两步是已核缺项的补齐方案,不是任意版本升级工具;重新执行前仍需核对目标实例与结构。
## 海外表和字段 ## 海外表和字段
+13 -3
View File
@@ -1,5 +1,15 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。
`overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。 车辆与司机档案照片复用:先执行 `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 或部署。
@@ -79,7 +89,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` 拼写。
@@ -102,7 +112,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 不算成功。
## 平台管理员密码 ## 平台管理员密码
@@ -203,7 +213,7 @@ 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)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
@@ -0,0 +1,11 @@
-- 仅用于单独授权的 TEST:修正作废应收仍占用原对账单的唯一约束。
-- 不更新、删除或回填业务记录;作废单的原对账单关联与修订历史保持不变。
-- 一条原子 ALTER 同时替换约束;有效应收重复、原索引缺失或目标列已存在均应报错停止。
-- 使用独立连接并开启遇错停止;若结果不明确,先核对结构,不盲目重放。
-- 前置:已部署 Finance 结算重开校验(fe89b8a2b875),目标为 MySQL 8 / InnoDB。
ALTER TABLE baiyue_finance.fin_settlement
ADD COLUMN active_receivable_statement_id BIGINT UNSIGNED
GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END)
VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留',
DROP INDEX uk_fin_settlement_receivable_statement,
ADD UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id, active_receivable_statement_id);
@@ -3,6 +3,16 @@
-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。 -- 前置:已完成 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
@@ -0,0 +1,97 @@
-- 指定 TEST 的海外费用/仓租结构补齐;实例必须由执行者另行核对并获准。
-- 仅新增 ll_storage_rent_inbound 和 ll_finance_fee_source.reviewer_name。
-- 使用独立连接,支持 DELIMITER,遇错停止;禁止 mysql --force。
-- 不更新费用、库存、钱包或历史计租数据,不重放空库初始化。
-- DDL 隐式提交;失败先回查结构,禁止删除已建表/字段来重跑。
USE baiyue_lastleg;
-- 仅清理本脚本可能遗留的专用校验过程,不删除业务对象。
DROP PROCEDURE IF EXISTS overseas_fee_rent_structure_add_20261026;
DELIMITER $$
CREATE PROCEDURE overseas_fee_rent_structure_add_20261026()
BEGIN
DECLARE inbound_exists INT DEFAULT 0;
DECLARE reviewer_exists INT DEFAULT 0;
IF DATABASE()<>'baiyue_lastleg' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg';
END IF;
-- 在首个业务 DDL 前核对两张依赖表及新增字段的定位列。
IF (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_finance_fee_source','ll_storage_rent_lot')
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB')<>2
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
AND COLUMN_NAME='reviewer_id' AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=128)<>1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用来源或计租批次基础结构不匹配,停止补齐';
END IF;
SELECT COUNT(*) INTO reviewer_exists FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name';
IF reviewer_exists>0 AND (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name'
AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=255 AND IS_NULLABLE='YES'
AND COLUMN_DEFAULT IS NULL AND EXTRA='')<>1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的审核人名称字段与正式定义不同,停止且不覆盖';
END IF;
SELECT COUNT(*) INTO inbound_exists FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound';
-- 已存在的同名表必须完整匹配;不以 IF NOT EXISTS 掩盖半成品或异构表。
IF inbound_exists>0 THEN
IF (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_unicode_ci')<>1
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA=''
AND ((COLUMN_NAME IN ('receipt_id','lot_id') AND COLUMN_TYPE='bigint')
OR (COLUMN_NAME='quantity' AND COLUMN_TYPE='decimal(24,12)')
OR (COLUMN_NAME='created_at' AND COLUMN_TYPE='datetime(3)')))<>4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的计租实收表字段与正式定义不同,停止且不覆盖';
END IF;
IF (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY')<>1
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY'
AND COLUMN_NAME='receipt_id' AND SEQ_IN_INDEX=1 AND NON_UNIQUE=0 AND SUB_PART IS NULL)<>1
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND INDEX_NAME='idx_storage_rent_inbound_lot')<>1
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND INDEX_NAME='idx_storage_rent_inbound_lot' AND COLUMN_NAME='lot_id'
AND SEQ_IN_INDEX=1 AND NON_UNIQUE=1 AND SUB_PART IS NULL)<>1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='计租实收表主键或批次索引不匹配,停止且不覆盖';
END IF;
END IF;
-- 全部前置检查通过后才补缺项;重跑时保持已存在的业务结构和数据。
IF reviewer_exists=0 THEN
ALTER TABLE ll_finance_fee_source
ADD COLUMN reviewer_name VARCHAR(255) NULL COMMENT '审核人名称快照' AFTER reviewer_id;
END IF;
IF inbound_exists=0 THEN
CREATE TABLE ll_storage_rent_inbound (
receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量',
lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键',
quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数',
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
PRIMARY KEY (receipt_id),
KEY idx_storage_rent_inbound_lot (lot_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记';
END IF;
END$$
DELIMITER ;
CALL overseas_fee_rent_structure_add_20261026();
DROP PROCEDURE overseas_fee_rent_structure_add_20261026;
SHOW CREATE TABLE ll_storage_rent_inbound;
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name';
SELECT 'TABLE_COLUMN_READY' AS structure_status;
-- TABLE_COLUMN_READY 只表示上述表/字段补齐;免费仓租另执行已审阅的
-- overseas-storage-rent-zero-fee-repair.sql,不能据此宣称完整功能上线。
@@ -0,0 +1,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
@@ -5044,6 +5044,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 +5052,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),
+38 -261
View File
@@ -1,5 +1,5 @@
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 -- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
-- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。 -- 海外费用资金、仓租四表、审核人名称快照及简化采购六表均在本文件最终建表定义中维护。
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
@@ -2060,53 +2060,6 @@ CREATE TABLE ll_outbound_pda_task (
UNIQUE KEY uk_ll_outbound_pda_complete_key (customer_id,completion_idempotency_key) UNIQUE KEY uk_ll_outbound_pda_complete_key (customer_id,completion_idempotency_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务';
CREATE TABLE ll_overseas_procurement_fee (
purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定',
confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作',
fee_version INT NULL COMMENT '同用途费用确认版本',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本',
determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用',
original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD',
original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额',
exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照',
exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份',
business_date DATE NULL COMMENT '实际费用业务日期',
wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录',
wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用',
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
id bigint NOT NULL COMMENT '主键ID',
order_id bigint NOT NULL COMMENT '采购订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
fee_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用业务号',
fee_item_code varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项编码',
fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称',
amount decimal(20,2) NOT NULL COMMENT 'CNY金额',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种',
voucher_file_id bigint NULL COMMENT '凭证文件ID',
voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NULL COMMENT '凭证文件名',
remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明',
wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态',
wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码',
wallet_error_message varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明',
wallet_command_event_id varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID',
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount >= 0)),
CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期';
CREATE TABLE ll_overseas_procurement_file ( CREATE TABLE ll_overseas_procurement_file (
id bigint NOT NULL COMMENT '主键ID', id bigint NOT NULL COMMENT '主键ID',
@@ -3977,245 +3930,33 @@ CREATE TABLE ll_return_carton_count_session (
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
CREATE TABLE ll_procurement_wallet_attempt (
id BIGINT NOT NULL COMMENT '请求尝试ID',
order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID',
attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号',
operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款',
business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号',
amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空',
command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用',
status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败',
result_hash CHAR(64) NULL COMMENT '原业务回执摘要',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用',
error_code VARCHAR(64) NULL COMMENT '业务失败码',
error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间',
completed_at DATETIME(3) NULL COMMENT '最终结果时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id),
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
KEY idx_procurement_attempt_order(customer_id,order_id,status),
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。 -- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。
CREATE TABLE ll_procurement_flow (
order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
purchased_at DATETIME(3) NULL COMMENT '真实购买时间',
purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品',
purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格',
purchased_quantity BIGINT NULL COMMENT '实际购买件数',
purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额',
purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD',
purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用',
purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明',
purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功',
actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID',
actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照',
actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照',
overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间',
packing_version INT NULL COMMENT '当前确认装箱清单版本',
overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间',
domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间',
charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间',
customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间',
domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人',
domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话',
domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址',
cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间',
closed_at DATETIME(3) NULL COMMENT '异常关闭时间',
control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
CREATE TABLE ll_procurement_command (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作',
idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据',
detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
CREATE TABLE ll_procurement_budget_authorization (
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种',
authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种',
voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据',
reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间',
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。 -- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。
CREATE TABLE ll_procurement_receipt (
id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内',
source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓',
good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量',
received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实',
supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
CREATE TABLE ll_procurement_disposition (
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源',
receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货',
quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实',
voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
CREATE TABLE ll_procurement_manifest (
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代',
confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
CREATE TABLE ll_procurement_carton (
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米',
width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米',
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
CREATE TABLE ll_procurement_carton_source (
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
CREATE TABLE ll_procurement_shipment (
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户',
manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间',
address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
CREATE TABLE ll_procurement_shipment_item (
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
USE baiyue_lastleg; USE baiyue_lastleg;
-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。 -- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。
CREATE TABLE ll_procurement_label_set (
id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本',
manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要',
sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照',
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
CREATE TABLE ll_procurement_label_item (
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始',
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
CREATE TABLE ll_procurement_estimate_review (
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
CREATE TABLE ll_procurement_repack (
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米',
height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克',
voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据',
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
USE baiyue_lastleg; USE baiyue_lastleg;
-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 -- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。
CREATE TABLE ll_procurement_fee_correction (
id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根',
idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要',
target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种',
exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份',
target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY',
difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录',
voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据',
created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
CREATE TABLE ll_procurement_fee_correction_item (
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
USE baiyue_lastleg; USE baiyue_lastleg;
-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 -- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。
CREATE TABLE ll_procurement_control_note (
id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型',
target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约',
voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明',
client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容',
original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
CREATE TABLE ll_procurement_closure (
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证',
financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实',
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
CREATE TABLE ll_procurement_closure_item (
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空',
quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置',
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
USE baiyue_lastleg; USE baiyue_lastleg;
@@ -5120,4 +4861,40 @@ CREATE TABLE ll_fleet_resource_photo (
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id) UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 240 AS initialized_tables; 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='采购操作幂等及审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables;
@@ -15,7 +15,7 @@ enum BusinessFileCategory {
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_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),
@@ -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核对文件复合归属。
@@ -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();
@@ -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,6 +141,7 @@ 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");
@@ -149,10 +161,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 +184,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())) {
@@ -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(),
@@ -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", "对账单修订快照损坏");
} }
+2 -1
View File
@@ -437,6 +437,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 +445,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', 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);
@@ -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)) {
filters.put("status", "BILLED"); filters.put("statementGenerated", true); jdbc.update("INSERT INTO fin_bill VALUES(91,9,'ISSUED')");
assertThat(repository.count(filters)).isEqualTo(1); jdbc.update("INSERT INTO fin_bill_line VALUES(2,2,9,91)");
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L); filters.put("status", "BILLED"); filters.put("statementGenerated", true);
session.clearCache();
assertThat(repository.count(filters)).isEqualTo(1);
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
assertThat(repository.detail(2).sourceStatus()).isEqualTo("BILLED");
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=91");
session.clearCache();
assertThat(repository.detail(2).statementGenerated()).isFalse();
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
} else {
jdbc.update("INSERT INTO fin_settlement VALUES(91,9,'PAYABLE','ISSUED','UNPAID')");
jdbc.update("INSERT INTO fin_settlement_line VALUES(91,9,2)");
for (String payment : List.of("UNPAID", "PARTIALLY_PAID", "PAID")) {
jdbc.update("UPDATE fin_settlement SET payment_status=? WHERE id=91", payment);
String expected = "UNPAID".equals(payment) ? "PAYABLE_BILLED" : payment;
filters.put("status", expected);
assertThat(repository.count(filters)).isEqualTo(1);
assertThat(repository.list(filters)).singleElement().satisfies(row -> {
assertThat(row.feeSourceId()).isEqualTo(2);
assertThat(row.sourceStatus()).isEqualTo(expected);
assertThat(row.statementGenerated()).isFalse();
assertThat(row.amount()).isEqualByComparingTo("120");
});
assertThat(repository.detail(2).sourceStatus()).isEqualTo(expected);
}
jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=91");
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
jdbc.update("UPDATE fin_settlement SET status='PAID',tenant_id=10 WHERE id=91");
jdbc.update("UPDATE fin_settlement_line SET tenant_id=10 WHERE settlement_id=91");
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
jdbc.update("UPDATE fin_fee_source SET settlement_mode='DIRECT_PAID',paid_at='2026-10-21 10:00:00' WHERE id=2");
assertThat(repository.detail(2).sourceStatus()).isEqualTo("PAID");
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=2");
assertThat(repository.detail(2).sourceStatus()).isEqualTo("VOIDED");
}
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。 // 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00"); 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("客户范围无效");
@@ -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)));
} }
@@ -350,6 +362,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");
@@ -589,9 +624,30 @@ class ReconciliationStatementBehaviorTest {
}; };
} }
/** 审批前后使用不同 DTO,实际序列化后的详情金额必须完整返回而非变成零。 */
@Test
void revisionReadsBeforeBillAndAfterDetailAmountsAndRejectsMissingAmount() throws Exception {
var service = serviceWithNumber(mock(JdbcTemplate.class));
var rs = mock(ResultSet.class);
when(rs.getString("action")).thenReturn("RECONCILIATION_ADJUST");
when(rs.getString("before_data")).thenReturn("{\"total\":240.00}");
when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":240.03}");
when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-10-27 10:00:00"));
com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision revision =
ReflectionTestUtils.invokeMethod(service, "revision", rs, 1);
assertThat(revision.beforeAmount()).isEqualByComparingTo("240.00");
assertThat(revision.afterAmount()).isEqualByComparingTo("240.03");
when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":9999999999999999.99}");
revision = ReflectionTestUtils.invokeMethod(service, "revision", rs, 1);
assertThat(revision.afterAmount()).isEqualByComparingTo("9999999999999999.99");
when(rs.getString("after_data")).thenReturn("{}");
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "revision", rs, 1))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("快照损坏");
}
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) { 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());
} }
} }
@@ -85,6 +85,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,
@@ -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) {}
} }
@@ -1,83 +1,172 @@
package com.baiyue.firstleg.sailingcalendar.mapper; package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.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 java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
/** /** 船期日历的只读投影;保留有效运输安排,未安排余量自然顺延,不改写业务归属。 */
* 船期日历的只读展示投影。
*
* 这里直接读取现有入库审核结果,不写入船期事实表,也不触发归班、改绑或智能排柜。
* 主子入库单只统计未拆分主单或仍可用的余量子单,避免把父单和子单重复计入。
*/
@Mapper @Mapper
public interface SailingCalendarDisplayMapper { public interface SailingCalendarDisplayMapper {
// 显式关联优先;无显式关联才按运输信息匹配。每条订舱只对应一个有效船期,
// 已确认货物允许保留在已截仓但尚未过期的船期,不能因为截仓就再次建议订柜。
// 跨表文本按字段完整长度转换为连接字符集,避免表间排序规则冲突,同时保留完整运输快照。
String BOOKING_TARGETS = """
SELECT b.id AS booking_id, (
SELECT s.id FROM fl_sailing s
WHERE s.etd_at >= #{todayStart} AND s.status IN ('OPEN', 'CLOSED')
AND (
EXISTS (SELECT 1 FROM fl_sailing_booking_link link
WHERE link.sailing_id = s.id AND link.ocean_booking_id = b.id
AND link.link_status = 'ACTIVE')
OR (
NOT EXISTS (SELECT 1 FROM fl_sailing_booking_link link
WHERE link.ocean_booking_id = b.id AND link.link_status = 'ACTIVE')
AND b.warehouse_id = s.warehouse_id
AND CAST(b.channel_code AS CHAR(64)) = CAST(s.channel_code_snapshot AS CHAR(64))
AND CAST(b.departure_port_code AS CHAR(32)) = CAST(s.departure_port_code AS CHAR(32))
AND CAST(b.destination_port_code AS CHAR(32)) = CAST(s.destination_port_code AS CHAR(32))
AND b.vessel_name IS NOT NULL AND b.voyage_no IS NOT NULL
AND CAST(b.vessel_name AS CHAR(128)) = CAST(s.vessel_name AS CHAR(128))
AND CAST(b.voyage_no AS CHAR(64)) = CAST(s.voyage_no AS CHAR(64))
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(s.etd_at))
)
)
ORDER BY s.etd_at, s.sailing_no, s.id LIMIT 1
) AS sailing_id
FROM fl_ocean_booking b WHERE b.status = 'RELEASED'
""";
// 普通箱分配可能在测量前创建,体积必须按最新已审核箱规重算;全量分配恰好等于实测,
// 按累计箱数分摊四位小数尾差,避免余下极小体积被向上取整为额外一柜。
// 已封托整托的体积是现场物理托盘快照,仅主明细保存体积,不能按多 SKU 行重复分摊。
String MEASURED_PLAN_ITEMS = """
SELECT quantities.id, quantities.loading_plan_id, quantities.account_id, quantities.inbound_id,
CASE WHEN quantities.unit_type = 'SEALED_PALLET' THEN quantities.pallet_volume_cbm
ELSE ROUND(quantities.measured_cbm * quantities.cumulative_boxes
/ NULLIF(GREATEST(quantities.measured_boxes, quantities.allocated_boxes), 0), 4)
- ROUND(quantities.measured_cbm * (quantities.cumulative_boxes - quantities.package_count)
/ NULLIF(GREATEST(quantities.measured_boxes, quantities.allocated_boxes), 0), 4)
END AS volume_cbm
FROM (
SELECT item.id, item.loading_plan_id, item.account_id, item.inbound_id, item.unit_type,
item.package_count, item.volume_cbm AS pallet_volume_cbm,
measured.package_count AS measured_boxes, measured.volume_cbm AS measured_cbm,
SUM(item.package_count) OVER (
PARTITION BY item.account_id, item.inbound_id, item.inbound_detail_id, item.unit_type
) AS allocated_boxes,
SUM(item.package_count) OVER (
PARTITION BY item.account_id, item.inbound_id, item.inbound_detail_id, item.unit_type
ORDER BY item.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_boxes
FROM fl_loading_plan_item item
JOIN fl_inbound_measurement batch
ON batch.tenant_id = item.account_id AND batch.inbound_id = item.inbound_id
AND batch.status = 'APPROVED'
AND batch.id = (SELECT MAX(latest.id) FROM fl_inbound_measurement latest
WHERE latest.tenant_id = item.account_id AND latest.inbound_id = item.inbound_id
AND latest.status = 'APPROVED')
LEFT JOIN (
SELECT tenant_id, measurement_id, inbound_detail_id,
SUM(package_count) AS package_count, SUM(total_volume_cbm) AS volume_cbm
FROM fl_inbound_measurement_item WHERE package_count > 0
GROUP BY tenant_id, measurement_id, inbound_detail_id
) measured ON measured.tenant_id = item.account_id AND measured.measurement_id = batch.id
AND measured.inbound_detail_id = item.inbound_detail_id
WHERE item.deleted = 0
) quantities
""";
/** 已订柜总数仍展示原订舱需求,可用容量按柜扣除已确认离仓部分,避免余货少订。 */
@Select(""" @Select("""
SELECT SELECT COALESCE(SUM(r.quantity), 0) AS containerCount,
COALESCE(SUM(r.quantity), 0) AS containerCount, COALESCE(SUM(r.capacity_cbm * r.quantity), 0) AS capacityCbm,
COALESCE(SUM(COALESCE(r.capacity_cbm, 0) * r.quantity), 0) AS capacityCbm COALESCE(SUM(r.capacity_cbm * GREATEST(r.quantity - COALESCE(d.departed_count, 0), 0)), 0) AS availableCapacityCbm
FROM fl_ocean_booking b FROM (
JOIN fl_ocean_booking_container_requirement r ON r.ocean_booking_id = b.id """ + BOOKING_TARGETS + """
WHERE b.status = 'RELEASED' ) target
AND ( JOIN fl_ocean_booking_container_requirement r ON r.ocean_booking_id = target.booking_id
EXISTS ( LEFT JOIN (
SELECT 1 FROM fl_sailing_booking_link link SELECT allocation.ocean_booking_id, allocation.requirement_id,
WHERE link.sailing_id = #{sailingId} COUNT(DISTINCT allocation.container_booking_id) AS departed_count
AND link.ocean_booking_id = b.id FROM fl_ocean_booking_container_allocation allocation
AND link.link_status = 'ACTIVE' WHERE EXISTS (SELECT 1 FROM fl_outbound_dispatch dispatch
) WHERE dispatch.container_booking_id = allocation.container_booking_id
OR ( AND dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL)
b.warehouse_id = #{warehouseId} GROUP BY allocation.ocean_booking_id, allocation.requirement_id
AND b.channel_code = #{channelCode} ) d ON d.ocean_booking_id = r.ocean_booking_id AND d.requirement_id = r.id
AND b.departure_port_code = #{departurePortCode} WHERE target.sailing_id = #{sailingId}
AND b.destination_port_code = #{destinationPortCode}
AND (#{vesselName} IS NOT NULL AND #{voyageNo} IS NOT NULL
AND b.vessel_name = #{vesselName}
AND b.voyage_no = #{voyageNo}
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(#{etdAt})))
)
)
""") """)
BookingSummary bookingSummary(@Param("sailingId") long sailingId, BookingSummary bookingSummary(@Param("sailingId") long sailingId, @Param("todayStart") LocalDateTime todayStart);
/** 按审核实测重算分配,跨表渠道统一文本排序规则后归入唯一船期,保持待发体积守恒。 */
@Select("""
SELECT COALESCE(SUM(CASE WHEN receipt_at <= #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS catchableCbm,
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS lateCbm,
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN 1 ELSE 0 END), 0) AS lateCount
FROM (
SELECT COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) AS receipt_at,
COALESCE(allocated.current_cbm, 0) + CASE WHEN #{sailingId} = (
SELECT next_sailing.id FROM fl_sailing next_sailing
WHERE next_sailing.warehouse_id = o.warehouse_id
AND CAST(next_sailing.channel_code_snapshot AS CHAR(64)) = CAST(o.channel_code AS CHAR(64))
AND next_sailing.status = 'OPEN'
AND next_sailing.etd_at >= #{todayStart}
AND next_sailing.warehouse_cutoff_at >= #{asOf}
ORDER BY next_sailing.etd_at, next_sailing.sailing_no, next_sailing.id LIMIT 1
) THEN GREATEST(o.actual_volume_cbm - COALESCE(allocated.reserved_cbm, 0), 0) ELSE 0 END AS volume_cbm
FROM fl_inbound_order o
LEFT JOIN (
SELECT item.account_id, item.inbound_id,
SUM(CASE WHEN (dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL)
OR target.sailing_id IS NOT NULL THEN item.volume_cbm ELSE 0 END) AS reserved_cbm,
SUM(CASE WHEN dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL THEN 0
WHEN target.sailing_id = #{sailingId} THEN item.volume_cbm ELSE 0 END) AS current_cbm
FROM (
""" + MEASURED_PLAN_ITEMS + """
) item
JOIN fl_loading_plan plan ON plan.id = item.loading_plan_id
JOIN fl_inbound_order source ON source.tenant_id = item.account_id AND source.id = item.inbound_id
LEFT JOIN fl_outbound_dispatch dispatch
ON dispatch.tenant_id = item.account_id AND dispatch.inbound_id = item.inbound_id
AND dispatch.container_booking_id = plan.container_booking_id
LEFT JOIN fl_ocean_booking_container_allocation allocation ON allocation.container_booking_id = plan.container_booking_id
LEFT JOIN (
""" + BOOKING_TARGETS + """
) target ON target.booking_id = allocation.ocean_booking_id
WHERE ((source.warehouse_id = #{warehouseId} AND source.channel_code = #{channelCode})
OR target.sailing_id = #{sailingId})
GROUP BY item.account_id, item.inbound_id
) allocated ON allocated.account_id = o.tenant_id AND allocated.inbound_id = o.id
WHERE o.deleted = 0 AND o.measure_review_status = 'APPROVED'
AND o.process_status IN ('WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED')
AND ((o.warehouse_id = #{warehouseId} AND o.channel_code = #{channelCode}) OR allocated.current_cbm > 0)
AND (COALESCE(o.split_status, 'NOT_SPLIT') = 'NOT_SPLIT'
OR (o.split_status = 'FULLY_SPLIT' AND o.process_status IN ('LOADING', 'LOADED')))
AND o.actual_volume_cbm IS NOT NULL
AND COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) <= #{asOf}
) received WHERE volume_cbm > 0
""")
ReceiptSummary receiptSummary(@Param("sailingId") long sailingId,
@Param("warehouseId") long warehouseId, @Param("warehouseId") long warehouseId,
@Param("channelCode") String channelCode, @Param("channelCode") String channelCode,
@Param("departurePortCode") String departurePortCode, @Param("cutoffAt") LocalDateTime cutoffAt,
@Param("destinationPortCode") String destinationPortCode, @Param("asOf") LocalDateTime asOf,
@Param("vesselName") String vesselName, @Param("todayStart") LocalDateTime todayStart);
@Param("voyageNo") String voyageNo,
@Param("etdAt") java.time.LocalDateTime etdAt);
/** 截仓节点跨月份、船名和港口筛选查询,避免隐藏的前班截仓后当前货量不刷新。 */
@Select(""" @Select("""
SELECT SELECT MIN(warehouse_cutoff_at) FROM fl_sailing
COALESCE(SUM(CASE WHEN receipt_at <= #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS catchableCbm, WHERE status = 'OPEN' AND etd_at >= #{todayStart} AND warehouse_cutoff_at >= #{asOf}
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS lateCbm, AND (#{query.warehouseId} IS NULL OR warehouse_id = #{query.warehouseId})
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN 1 ELSE 0 END), 0) AS lateCount AND (#{query.channelCode} IS NULL OR channel_code_snapshot = #{query.channelCode})
FROM (
SELECT
COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) AS receipt_at,
COALESCE(o.actual_volume_cbm, 0) AS volume_cbm
FROM fl_inbound_order o
WHERE o.deleted = 0
AND o.measure_review_status = 'APPROVED'
AND o.warehouse_id = #{warehouseId}
AND o.channel_code = #{channelCode}
AND (
(o.inbound_role = 'MASTER' AND COALESCE(o.split_status, 'NOT_SPLIT') = 'NOT_SPLIT')
OR (o.inbound_role = 'CHILD' AND o.split_purpose = 'REMAINDER' AND o.split_status = 'NOT_SPLIT')
)
AND o.actual_volume_cbm IS NOT NULL
AND (#{fromAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) > #{fromAt})
AND (#{toAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) <= #{toAt})
) received
""") """)
ReceiptSummary receiptSummary(@Param("warehouseId") long warehouseId, LocalDateTime nextCutoff(@Param("query") SailingQuery query, @Param("asOf") LocalDateTime asOf,
@Param("channelCode") String channelCode, @Param("todayStart") LocalDateTime todayStart);
@Param("cutoffAt") java.time.LocalDateTime cutoffAt,
@Param("fromAt") java.time.LocalDateTime fromAt,
@Param("toAt") java.time.LocalDateTime toAt);
record ReceiptSummary(BigDecimal catchableCbm, BigDecimal lateCbm, Integer lateCount) {} record ReceiptSummary(BigDecimal catchableCbm, BigDecimal lateCbm, Integer lateCount) {}
record BookingSummary(Integer containerCount, BigDecimal capacityCbm) {} record BookingSummary(Integer containerCount, BigDecimal capacityCbm, BigDecimal availableCapacityCbm) {}
} }
@@ -132,13 +132,17 @@ public class SailingCalendarRepository {
} }
public OceanBookingEntity booking(long id) { return oceanBookings.find(id); } public OceanBookingEntity booking(long id) { return oceanBookings.find(id); }
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return oceanBookings.requirements(id); } public List<OceanBookingContainerRequirementEntity> requirements(long id) { return oceanBookings.requirements(id); }
public SailingCalendarDisplayMapper.ReceiptSummary receiptSummary(SailingEntity sailing) { /** 按当前时刻查询未离仓货量的下一有效船期展示投影,不写入货物或订舱归属。 */
return display.receiptSummary(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), public SailingCalendarDisplayMapper.ReceiptSummary receiptSummary(SailingEntity sailing, LocalDateTime asOf) {
sailing.getWarehouseCutoffAt(), previousCutoff(sailing), nextCutoff(sailing)); return display.receiptSummary(sailing.getId(), sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
sailing.getWarehouseCutoffAt(), asOf, asOf.toLocalDate().atStartOfDay());
} }
public SailingCalendarDisplayMapper.BookingSummary bookingSummary(SailingEntity sailing) { /** 订舱总需求和未离仓可用容量分别返回,使用与货量投影相同的自然日。 */
return display.bookingSummary(sailing.getId(), sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), public SailingCalendarDisplayMapper.BookingSummary bookingSummary(SailingEntity sailing, LocalDateTime asOf) {
sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getVesselName(), return display.bookingSummary(sailing.getId(), asOf.toLocalDate().atStartOfDay());
sailing.getVoyageNo(), sailing.getEtdAt()); }
/** 查找会影响当前仓库、渠道展示的下一截仓时刻,范围不受所选月份限制。 */
public LocalDateTime nextCutoff(SailingQuery query, LocalDateTime asOf) {
return display.nextCutoff(query, asOf, asOf.toLocalDate().atStartOfDay());
} }
} }
@@ -17,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
@@ -43,6 +44,7 @@ public class SailingCalendarService {
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private Clock clock = Clock.system(ZONE);
private static final String OPEN = "OPEN"; private static final String OPEN = "OPEN";
@Resource private SailingCalendarRepository repository; @Resource private SailingCalendarRepository repository;
@Resource private PriceBookQueryUseCase priceBooks; @Resource private PriceBookQueryUseCase priceBooks;
@@ -93,12 +95,17 @@ public class SailingCalendarService {
return requiredTemplate(id); return requiredTemplate(id);
} }
/** 月历和汇总只纳入上海时区今天及之后开船的计划,历史计划按日期自然过期。 */
public CalendarResult calendar(SailingQuery query) { public CalendarResult calendar(SailingQuery query) {
LocalDate first; LocalDate first;
try { first = LocalDate.parse(query.month() + "-01"); } catch (RuntimeException ex) { throw bad("month必须为yyyy-MM"); } try { first = LocalDate.parse(query.month() + "-01"); } catch (RuntimeException ex) { throw bad("month必须为yyyy-MM"); }
LocalDate next = first.plusMonths(1); LocalDate next = first.plusMonths(1);
List<SailingEntity> values = repository.calendar(query, first.atStartOfDay(), next.atStartOfDay()); LocalDateTime asOf = LocalDateTime.now(clock.withZone(ZONE));
List<CalendarItem> items = values.stream().map(this::item).toList(); LocalDate today = asOf.toLocalDate();
LocalDate start = first.isBefore(today) ? today : first;
List<SailingEntity> values = start.isBefore(next)
? repository.calendar(query, start.atStartOfDay(), next.atStartOfDay()) : List.of();
List<CalendarItem> items = values.stream().map(value -> item(value, asOf)).toList();
BigDecimal capacity = items.stream().map(v -> v.volumeSummary().planningCapacityCbm() == null ? BigDecimal.ZERO : v.volumeSummary().planningCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal capacity = items.stream().map(v -> v.volumeSummary().planningCapacityCbm() == null ? BigDecimal.ZERO : v.volumeSummary().planningCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal actual = items.stream().map(v -> v.volumeSummary().actualReceivedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal actual = items.stream().map(v -> v.volumeSummary().actualReceivedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal forecast = items.stream().map(v -> v.volumeSummary().catchableForecastCbm()).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal forecast = items.stream().map(v -> v.volumeSummary().catchableForecastCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
@@ -109,17 +116,23 @@ public class SailingCalendarService {
int late = items.stream().mapToInt(v -> v.volumeSummary().lateBatchCount()).sum(); int late = items.stream().mapToInt(v -> v.volumeSummary().lateBatchCount()).sum();
BigDecimal catchableReceived = items.stream().map(v -> nvl(v.volumeSummary().catchableReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal catchableReceived = items.stream().map(v -> nvl(v.volumeSummary().catchableReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal lateReceived = items.stream().map(v -> nvl(v.volumeSummary().lateReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal lateReceived = items.stream().map(v -> nvl(v.volumeSummary().lateReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived); BigDecimal availableBookedCapacity = items.stream().map(v -> v.volumeSummary().availableBookedCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived, availableBookedCapacity);
LocalDateTime nextRefreshAt = today.plusDays(1).atStartOfDay();
LocalDateTime nextCutoff = start.isBefore(next) ? repository.nextCutoff(query, asOf) : null;
// 截止时间仍包含该毫秒,跨过后再刷新;隐藏月份和筛选之外的前班也参与调度。
if (nextCutoff != null && nextCutoff.plusNanos(1_000_000).isBefore(nextRefreshAt)) nextRefreshAt = nextCutoff.plusNanos(1_000_000);
return new CalendarResult(query.month(), ZONE.getId(), items, summary, return new CalendarResult(query.month(), ZONE.getId(), items, summary,
new CalendarResult.FilterOptions( new CalendarResult.FilterOptions(
values.stream().map(v -> new CalendarResult.Option(String.valueOf(v.getWarehouseId()), v.getWarehouseNameSnapshot())).distinct().toList(), values.stream().map(v -> new CalendarResult.Option(String.valueOf(v.getWarehouseId()), v.getWarehouseNameSnapshot())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getChannelCodeSnapshot(), v.getChannelNameSnapshot())).distinct().toList(), values.stream().map(v -> new CalendarResult.Option(v.getChannelCodeSnapshot(), v.getChannelNameSnapshot())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getDeparturePortCode(), v.getDeparturePortCode())).distinct().toList(), values.stream().map(v -> new CalendarResult.Option(v.getDeparturePortCode(), v.getDeparturePortCode())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList())); values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList()), nextRefreshAt);
} }
/** 单次详情使用同一上海业务时刻读取船期货量。 */
public SailingDetail detail(long id) { public SailingDetail detail(long id) {
SailingEntity value = requiredSailing(id); SailingEntity value = requiredSailing(id);
return new SailingDetail(item(value), history(id)); return new SailingDetail(item(value, LocalDateTime.now(clock.withZone(ZONE))), history(id));
} }
public List<SailingHistoryView> history(long id) { public List<SailingHistoryView> history(long id) {
requiredSailing(id); requiredSailing(id);
@@ -321,6 +334,7 @@ public class SailingCalendarService {
} }
recordHistory(value.getId(), "COPY", null, OPEN, copyReason, operatorId, operatorName, value.getCreatedAt()); return detail(value.getId()); recordHistory(value.getId(), "COPY", null, OPEN, copyReason, operatorId, operatorName, value.getCreatedAt()); return detail(value.getId());
} }
/** 返回当前未离仓货量投影及已有归属记录,不创建新归属。 */
public VolumeDetail volumeSummary(long id) { public VolumeDetail volumeSummary(long id) {
SailingEntity value = requiredSailing(id); SailingEntity value = requiredSailing(id);
List<AssignmentView> assignments = repository.assignments(id).stream().map(a -> { List<AssignmentView> assignments = repository.assignments(id).stream().map(a -> {
@@ -328,20 +342,20 @@ public class SailingCalendarService {
? null : repository.receiptFact(a.getReceiptBatchId()).getReceivedAt(); ? null : repository.receiptFact(a.getReceiptBatchId()).getReceivedAt();
return new AssignmentView(String.valueOf(a.getId()), a.getReceiptBatchId() == null ? null : String.valueOf(a.getReceiptBatchId()), a.getForecastId() == null ? null : String.valueOf(a.getForecastId()), a.getInboundCode(), receivedAt, nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), a.getMatchStatus(), a.getAssignmentSource(), a.getReason(), a.getAssignmentVersion()); return new AssignmentView(String.valueOf(a.getId()), a.getReceiptBatchId() == null ? null : String.valueOf(a.getReceiptBatchId()), a.getForecastId() == null ? null : String.valueOf(a.getForecastId()), a.getInboundCode(), receivedAt, nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), a.getMatchStatus(), a.getAssignmentSource(), a.getReason(), a.getAssignmentVersion());
}).toList(); }).toList();
return new VolumeDetail(summary(value), assignments); return new VolumeDetail(summary(value, LocalDateTime.now(clock.withZone(ZONE))), assignments);
} }
/** 按全部已收和可核验预计货量向上取整,晚到量保留风险提示但不漏算订柜需求。 */
public BookingAdvice bookingAdvice(long id) { public BookingAdvice bookingAdvice(long id) {
SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value); SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value, LocalDateTime.now(clock.withZone(ZONE)));
BigDecimal catchable = catchableVolume(value.getId()); BigDecimal catchable = s.catchableReceivedCbm().add(s.catchableForecastCbm());
int recommended = recommendedContainers(catchable); BigDecimal demand = s.actualReceivedCbm().add(s.catchableForecastCbm());
BigDecimal bookedCapacity = bookedPlanningCapacity(value.getId()); BigDecimal shortage = demand.subtract(s.availableBookedCapacityCbm());
int pending = ceil(nvl(catchable.subtract(bookedCapacity)), defaultContainerCapacity());
List<BookingScenario> scenarios = ContainerSpecificationCatalog.values().stream().map(spec -> { List<BookingScenario> scenarios = ContainerSpecificationCatalog.values().stream().map(spec -> {
int count = ceil(catchable, spec.capacityCbm()); int count = ceil(demand, spec.capacityCbm());
int pendingForType = ceil(nvl(catchable.subtract(bookedCapacity)), spec.capacityCbm()); int pendingForType = ceil(shortage, spec.capacityCbm());
return new BookingScenario(spec.containerType(), spec.containerType(), spec.capacityCbm(), count, pendingForType); return new BookingScenario(spec.containerType(), spec.containerType(), spec.capacityCbm(), count, pendingForType);
}).toList(); }).toList();
return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), recommended, pending, scenarios, LocalDateTime.now(ZONE), s.catchableReceivedCbm(), s.lateReceivedCbm()); return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), s.recommendedContainerCount(), s.pendingContainerCount(), scenarios, s.snapshotAt(), s.catchableReceivedCbm(), s.lateReceivedCbm());
} }
@Transactional @Transactional
public Map<String, Object> linkBooking(long id, long operatorId, String operatorName, BookingLinkRequest request) { public Map<String, Object> linkBooking(long id, long operatorId, String operatorName, BookingLinkRequest request) {
@@ -410,9 +424,11 @@ public class SailingCalendarService {
private void recordHistory(long sailingId, String action, String fromStatus, String toStatus, String reason, long operatorId, String operatorName, LocalDateTime at) { private void recordHistory(long sailingId, String action, String fromStatus, String toStatus, String reason, long operatorId, String operatorName, LocalDateTime at) {
SailingHistoryEntity history = new SailingHistoryEntity(); history.setId(IdWorker.getId()); history.setSailingId(sailingId); history.setAction(action); history.setFromStatus(fromStatus); history.setToStatus(toStatus); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setCreatedAt(at); repository.insertHistory(history); SailingHistoryEntity history = new SailingHistoryEntity(); history.setId(IdWorker.getId()); history.setSailingId(sailingId); history.setAction(action); history.setFromStatus(fromStatus); history.setToStatus(toStatus); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setCreatedAt(at); repository.insertHistory(history);
} }
private CalendarItem item(SailingEntity v) { return new CalendarItem(v.getId(), v.getSailingNo(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getVesselName(), v.getVoyageNo(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getEtdAt(), v.getWarehouseCutoffAt(), v.getStatus(), v.getVersion(), summary(v), v.getTemplateId(), v.getChannelId(), v.getEtaAt(), v.getSourceType(), v.getPlannedCapacityCbm()); } /** 整次月历共用查询时刻,避免跨过截止瞬间时把同一货量计入两班。 */
private VolumeSummary summary(SailingEntity v) { private CalendarItem item(SailingEntity v, LocalDateTime asOf) { return new CalendarItem(v.getId(), v.getSailingNo(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getVesselName(), v.getVoyageNo(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getEtdAt(), v.getWarehouseCutoffAt(), v.getStatus(), v.getVersion(), summary(v, asOf), v.getTemplateId(), v.getChannelId(), v.getEtaAt(), v.getSourceType(), v.getPlannedCapacityCbm()); }
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v); /** 统一日历、详情与订柜初算的货量和已订容量口径。 */
private VolumeSummary summary(SailingEntity v, LocalDateTime asOf) {
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v, asOf);
BigDecimal eligibleActual = received == null ? BigDecimal.ZERO : nvl(received.catchableCbm()); BigDecimal eligibleActual = received == null ? BigDecimal.ZERO : nvl(received.catchableCbm());
BigDecimal lateActual = received == null ? BigDecimal.ZERO : nvl(received.lateCbm()); BigDecimal lateActual = received == null ? BigDecimal.ZERO : nvl(received.lateCbm());
BigDecimal actual = eligibleActual.add(lateActual); BigDecimal actual = eligibleActual.add(lateActual);
@@ -420,12 +436,14 @@ public class SailingCalendarService {
// 当前展示版不再产生“未归班”事实;没有可靠的预计体积源时不把它冒充成待确认货量。 // 当前展示版不再产生“未归班”事实;没有可靠的预计体积源时不把它冒充成待确认货量。
BigDecimal risk = BigDecimal.ZERO; BigDecimal risk = BigDecimal.ZERO;
int late = received == null || received.lateCount() == null ? 0 : received.lateCount(); int late = received == null || received.lateCount() == null ? 0 : received.lateCount();
BookingTotals bookedTotals = bookingTotals(v); BookingTotals bookedTotals = bookingTotals(v, asOf);
BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = catchableVolume(v.getId()); BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = eligibleActual.add(forecast);
boolean over = capacity != null && catchable.compareTo(capacity) > 0; boolean over = capacity != null && catchable.compareTo(capacity) > 0;
int recommended = recommendedContainers(catchable); // 截止时间仅拆分参考量与风险量;有实收货量就按统一柜型容量向上取整。
int pending = ceil(nvl(catchable.subtract(bookedTotals.capacityCbm())), defaultContainerCapacity()); BigDecimal demand = actual.add(forecast);
return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), LocalDateTime.now(ZONE), eligibleActual, lateActual); int recommended = recommendedContainers(demand);
int pending = ceil(demand.subtract(bookedTotals.availableCapacityCbm()), defaultContainerCapacity());
return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), asOf, eligibleActual, lateActual, bookedTotals.availableCapacityCbm());
} }
private static int recommendedContainers(BigDecimal volume) { return ceil(volume, defaultContainerCapacity()); } private static int recommendedContainers(BigDecimal volume) { return ceil(volume, defaultContainerCapacity()); }
private static BigDecimal defaultContainerCapacity() { private static BigDecimal defaultContainerCapacity() {
@@ -435,18 +453,14 @@ public class SailingCalendarService {
.findFirst() .findFirst()
.orElseThrow(() -> new IllegalStateException("默认柜型未配置")); .orElseThrow(() -> new IllegalStateException("默认柜型未配置"));
} }
private BigDecimal catchableVolume(long sailingId) { /** 已订总量用于展示,仍可承接货物的容量才参与待补柜扣减。 */
SailingEntity sailing = requiredSailing(sailingId); private BookingTotals bookingTotals(SailingEntity sailing, LocalDateTime asOf) {
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(sailing); SailingCalendarDisplayMapper.BookingSummary value = repository.bookingSummary(sailing, asOf);
return received == null ? BigDecimal.ZERO : nvl(received.catchableCbm());
}
private BigDecimal bookedPlanningCapacity(long sailingId) { return bookingTotals(requiredSailing(sailingId)).capacityCbm(); }
private BookingTotals bookingTotals(SailingEntity sailing) {
SailingCalendarDisplayMapper.BookingSummary value = repository.bookingSummary(sailing);
return new BookingTotals(value == null || value.containerCount() == null ? 0 : value.containerCount(), return new BookingTotals(value == null || value.containerCount() == null ? 0 : value.containerCount(),
value == null ? BigDecimal.ZERO : nvl(value.capacityCbm())); value == null ? BigDecimal.ZERO : nvl(value.capacityCbm()),
value == null ? BigDecimal.ZERO : nvl(value.availableCapacityCbm()));
} }
private record BookingTotals(int containerCount, BigDecimal capacityCbm) {} private record BookingTotals(int containerCount, BigDecimal capacityCbm, BigDecimal availableCapacityCbm) {}
private static int ceil(BigDecimal volume, BigDecimal unit) { return volume == null || volume.signum() <= 0 ? 0 : volume.divide(unit, 0, java.math.RoundingMode.CEILING).intValue(); } private static int ceil(BigDecimal volume, BigDecimal unit) { return volume == null || volume.signum() <= 0 ? 0 : volume.divide(unit, 0, java.math.RoundingMode.CEILING).intValue(); }
private static BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private static BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private static void copy(SailingChannelEntity v, ChannelData d) { v.setChannelCode(d.channelCode().trim().toUpperCase()); v.setChannelName(d.channelName().trim()); v.setSourceType(d.sourceType().trim().toUpperCase()); v.setMappedChannelCode(trimUpper(d.mappedChannelCode())); v.setDefaultDeparturePortCode(trimUpper(d.defaultDeparturePortCode())); v.setDefaultDestinationPortCode(trimUpper(d.defaultDestinationPortCode())); v.setDefaultCutoffDays(d.defaultCutoffDays() == null ? 3 : d.defaultCutoffDays()); v.setRemark(trim(d.remark())); } private static void copy(SailingChannelEntity v, ChannelData d) { v.setChannelCode(d.channelCode().trim().toUpperCase()); v.setChannelName(d.channelName().trim()); v.setSourceType(d.sourceType().trim().toUpperCase()); v.setMappedChannelCode(trimUpper(d.mappedChannelCode())); v.setDefaultDeparturePortCode(trimUpper(d.defaultDeparturePortCode())); v.setDefaultDestinationPortCode(trimUpper(d.defaultDestinationPortCode())); v.setDefaultCutoffDays(d.defaultCutoffDays() == null ? 3 : d.defaultCutoffDays()); v.setRemark(trim(d.remark())); }
@@ -7,6 +7,7 @@ paths:
/api/platform/v1/sailings/calendar: /api/platform/v1/sailings/calendar:
get: get:
summary: 查询船期月历 summary: 查询船期月历
description: 按 Asia/Shanghai 自然日仅返回今天及之后开船的船期和同范围汇总。有效已放舱订舱对应的排柜货量保留在其 OPEN/CLOSED 船期,截仓后仍保留;未安排或原船期取消、日期过期的未离仓余量,计入同仓、同渠道最近一班 OPEN 且尚未截仓的船期,按开船时间、船期编号和 ID 稳定排序。只调整展示投影,不改写订舱或排柜归属。响应 nextRefreshAt 为上海本地时间,取下一零点和同仓渠道范围内最近截仓时刻之后 1 毫秒的较早值,截仓查询不受展示月份、船名和港口筛选限制。
x-permission: platform:firstleg:sailing-calendar:list x-permission: platform:firstleg:sailing-calendar:list
parameters: parameters:
- {name: month, in: query, required: true, schema: {type: string, pattern: '^\\d{4}-\\d{2}$'}} - {name: month, in: query, required: true, schema: {type: string, pattern: '^\\d{4}-\\d{2}$'}}
@@ -17,7 +18,7 @@ paths:
- {name: destinationPortCode, in: query, schema: {type: string, maxLength: 32}} - {name: destinationPortCode, in: query, schema: {type: string, maxLength: 32}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}} - {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: status, in: query, schema: {type: string}} - {name: status, in: query, schema: {type: string}}
responses: {'200': {description: 月历和按船期汇总}} responses: {'200': {description: 月历和按船期汇总;nextRefreshAt 为下次自动刷新时间,volumeSummary 和 summary 的 availableBookedCapacityCbm 为扣除已离仓柜后的可用订舱容量,各船期 volumeSummary 的 bookedPlanningCapacityCbm 保留订舱总容量。}}
/api/platform/v1/sailings: /api/platform/v1/sailings:
post: post:
summary: 新增单次船期 summary: 新增单次船期
@@ -58,6 +59,7 @@ paths:
/api/platform/v1/sailings/{id}/booking-advice: /api/platform/v1/sailings/{id}/booking-advice:
get: get:
summary: 查询只读订柜建议 summary: 查询只读订柜建议
description: 建议柜数按当前展示的全部待发实测与可核验预计体积除以统一柜型容量向上取整,正货量至少一柜;待补柜先扣除未离仓柜的可用订舱容量后再向上取整,下限为零。已离仓柜按所属柜型需求整柜扣除容量,同柜多票只扣一次。日历、汇总和柜型方案使用相同口径。
x-permission: platform:firstleg:sailing-calendar:list x-permission: platform:firstleg:sailing-calendar:list
responses: {'200': {description: OK}} responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/booking-links: /api/platform/v1/sailings/{id}/booking-links:
@@ -0,0 +1,388 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class SailingCalendarDisplayMapperTest {
private JdbcTemplate jdbc;
private SqlSession session;
private SailingCalendarDisplayMapper mapper;
/** 使用隔离内存库执行实际 Mapper,覆盖顺延、离仓扣减和去重规则。 */
@BeforeEach
void setUp() {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE fl_sailing(id BIGINT, sailing_no VARCHAR, warehouse_id BIGINT, channel_code_snapshot VARCHAR, status VARCHAR, etd_at TIMESTAMP, warehouse_cutoff_at TIMESTAMP, departure_port_code VARCHAR, destination_port_code VARCHAR, vessel_name VARCHAR, voyage_no VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT, tenant_id BIGINT, deleted INT, measure_review_status VARCHAR, process_status VARCHAR, warehouse_id BIGINT, channel_code VARCHAR, split_status VARCHAR, actual_volume_cbm DECIMAL(18,6), inbounded_at TIMESTAMP, measure_completed_at TIMESTAMP, created_at TIMESTAMP, total_package_count INT)");
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT, container_booking_id BIGINT)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT, loading_plan_id BIGINT, account_id BIGINT, inbound_id BIGINT, deleted INT, volume_cbm DECIMAL(18,6), inbound_detail_id BIGINT DEFAULT 1, package_count INT DEFAULT 0, unit_type VARCHAR DEFAULT 'PACKAGE')");
jdbc.execute("CREATE TABLE fl_inbound_measurement(id BIGINT, tenant_id BIGINT, inbound_id BIGINT, status VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_measurement_item(id BIGINT, tenant_id BIGINT, measurement_id BIGINT, inbound_detail_id BIGINT, package_count INT, total_volume_cbm DECIMAL(18,4))");
jdbc.execute("CREATE TABLE fl_outbound_dispatch(tenant_id BIGINT, inbound_id BIGINT, container_booking_id BIGINT, status VARCHAR, confirmed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_ocean_booking(id BIGINT,status VARCHAR,warehouse_id BIGINT,channel_code VARCHAR,departure_port_code VARCHAR,destination_port_code VARCHAR,vessel_name VARCHAR,voyage_no VARCHAR,estimated_sailing_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_ocean_booking_container_requirement(id BIGINT,ocean_booking_id BIGINT,quantity INT,capacity_cbm DECIMAL(18,6))");
jdbc.execute("CREATE TABLE fl_ocean_booking_container_allocation(ocean_booking_id BIGINT,requirement_id BIGINT,container_booking_id BIGINT)");
jdbc.execute("CREATE TABLE fl_sailing_booking_link(sailing_id BIGINT,ocean_booking_id BIGINT,link_status VARCHAR)");
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), source));
configuration.addMapper(SailingCalendarDisplayMapper.class);
session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession();
mapper = session.getMapper(SailingCalendarDisplayMapper.class);
sailing(1, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
sailing(2, "2026-11-04T10:00:00", "2026-11-03T18:00:00", "OPEN");
}
/** 释放本测试的会话和内存表。 */
@AfterEach
void tearDown() {
session.close();
jdbc.execute("DROP ALL OBJECTS");
}
/** 测量前全票占位无论高估、低估或箱数调整,都只计入审核后的实测货量。 */
@ParameterizedTest
@CsvSource({"100,6000", "40,6000", "100,10000"})
void recomputesDeclaredAllocationFromApprovedMeasurement(String declaredVolume, int allocatedBoxes) {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "60");
jdbc.update("UPDATE fl_loading_plan_item SET volume_cbm=?,package_count=?", new java.math.BigDecimal(declaredVolume), allocatedBoxes);
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
assertThat(advice(2, "2026-10-27T12:00:00").recommendedContainerCount()).isEqualTo(1);
assertThat(advice(2, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
}
/** 不同规格按自己的实测单箱体积计算,避免用整票均值错误分配部分货量。 */
@Test
void computesPartialAllocationsUsingEachMeasuredSpecification() {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET total_package_count=3000 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET package_count=1000,total_volume_cbm=10 WHERE id=10");
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(11,7,10,2,2000,50)");
allocate(1, 100, "5");
allocate(1, 200, "5");
jdbc.update("UPDATE fl_loading_plan_item SET inbound_detail_id=2 WHERE id=200");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("42.5");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("17.5");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,200,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("5");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("42.5");
}
/** 三等分等循环小数的分摊总量仍等于实测,不产生额外余量补柜建议。 */
@Test
void conservesMeasuredVolumeAcrossRoundedAllocations() {
booking(2, 3, "68");
inbound(1, "1", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET total_package_count=3 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET package_count=3 WHERE id=10");
allocate(1, 100, "0.01");
allocate(1, 200, "0.01");
allocate(1, 300, "0.01");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("1");
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("0.6667");
}
/** 只读取最新已审核批次,同明细多箱规累加,排除待审核批次及其他客户。 */
@Test
void usesOnlyTheLatestApprovedMeasurementForTheAllocationOwner() {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "60");
jdbc.update("UPDATE fl_inbound_measurement_item SET total_volume_cbm=100 WHERE id=10");
jdbc.update("INSERT INTO fl_inbound_measurement VALUES(20,7,1,'APPROVED'),(30,7,1,'SUBMITTED'),(40,8,1,'APPROVED')");
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(20,7,20,1,2000,10),(21,7,20,1,4000,50),(30,7,30,1,6000,999),(40,8,40,1,6000,999)");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
}
/** 一件代发整托只使用主行的物理托盘体积,附加 SKU 行不能再次增加货量。 */
@Test
void keepsSealedPalletPhysicalVolumeWithoutDuplicatingSkuLines() {
booking(2, 1, "68");
inbound(1, "20", "LOADING", "NOT_SPLIT");
allocate(1, 100, "20");
jdbc.update("UPDATE fl_loading_plan_item SET unit_type='SEALED_PALLET'");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,inbound_detail_id,package_count,unit_type) VALUES(200,100,7,1,0,0,2,2000,'SEALED_PALLET')");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("20");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
/** 老货不按原收货窗口丢失,截止前只计入最近一班,截止后跨月顺延。 */
@Test
void rollsUnshippedBacklogForwardOnlyOnceAndAcrossMonths() {
inbound(1, "113.74", "WAIT_SPLIT", "NOT_SPLIT");
assertThat(summary(1, "2026-10-27T18:00:00").catchableCbm()).isEqualByComparingTo("113.74");
assertThat(summary(2, "2026-10-27T18:00:00").catchableCbm()).isZero();
assertThat(summary(1, "2026-10-27T18:00:01").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T18:00:01").catchableCbm()).isEqualByComparingTo("113.74");
assertThat(summary(2, "2026-11-02T12:00:00").catchableCbm()).isEqualByComparingTo("113.74");
}
/** 取消、截止和过期航次不承接货量;同一时间的两班也只选择一班。 */
@Test
void skipsIneligibleSailingsAndBreaksTiesDeterministically() {
inbound(1, "12", "WAIT_SPLIT", "NOT_SPLIT");
sailing(3, "2026-10-26T10:00:00", "2026-10-29T18:00:00", "OPEN");
sailing(4, "2026-10-27T15:00:00", "2026-10-27T14:00:00", "CANCELLED");
sailing(5, "2026-10-27T16:00:00", "2026-10-27T14:00:00", "CLOSED");
sailing(6, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("12");
for (long id : new long[]{2, 3, 4, 5, 6}) {
assertThat(summary(id, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
}
/** 实际离仓才扣减,部分离仓只扣该柜明细,撤销离仓后重新纳入。 */
@Test
void subtractsOnlyConfirmedDeparturesAndRestoresReversedVolume() {
inbound(1, "100", "LOADED", "NOT_SPLIT");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,100),(20,200)");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(1,10,7,1,0,40,4000),(2,20,7,1,0,60,6000),(3,10,8,1,0,999,99900),(4,10,7,1,1,999,99900)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00'),(7,1,200,'READY_FOR_DISPATCH',NULL),(8,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00')");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
jdbc.update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH' WHERE tenant_id=7 AND container_booking_id=100");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("UPDATE fl_outbound_dispatch SET status='WAITING_FOR_LOADING',confirmed_at='2026-10-27 11:00:00' WHERE tenant_id=7");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
/** 拣货结算后的源单和余量已各自保存实测体积,保留未离仓部分并排除失效数据。 */
@Test
void includesLoadedSplitSourceAndRemainderWithoutCountingInvalidReceipts() {
inbound(1, "40", "LOADED", "FULLY_SPLIT");
inbound(2, "60", "WAIT_SPLIT", "NOT_SPLIT");
inbound(3, "100", "WAIT_SPLIT", "PARTIALLY_SPLIT");
inbound(4, "999", "DELIVERED", "NOT_SPLIT");
inbound(5, "999", "CANCELLED", "NOT_SPLIT");
inbound(6, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(7, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(8, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(9, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(10, "999", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET warehouse_id=99 WHERE id=6");
jdbc.update("UPDATE fl_inbound_order SET channel_code='OTHER' WHERE id=7");
jdbc.update("UPDATE fl_inbound_order SET deleted=1 WHERE id=8");
jdbc.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=9");
jdbc.update("UPDATE fl_inbound_order SET inbounded_at='2026-10-28 10:00:00' WHERE id=10");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,100)");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(1,10,7,1,0,40,4000)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00')");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
}
/** 无可赶上的船期时不把货量展示在已截止航次,新建有效船期后可再次承接。 */
@Test
void waitsForANewEligibleSailingWithoutLosingTheBacklog() {
inbound(1, "10", "WAIT_SPLIT", "NOT_SPLIT");
assertThat(summary(2, "2026-11-04T12:00:00").catchableCbm()).isZero();
sailing(3, "2026-11-11T10:00:00", "2026-11-10T18:00:00", "OPEN");
assertThat(summary(3, "2026-11-04T12:00:00").catchableCbm()).isEqualByComparingTo("10");
}
/** 已离仓柜的总需求仍可查看,但其容量不能继续抵扣仓内余货。 */
@Test
void excludesDepartedContainerCapacityFromRemainingCargoAdvice() {
booking(1, 1, "68");
inbound(1, "68", "LOADED", "NOT_SPLIT");
inbound(2, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
var booked = mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00"));
assertThat(booked.containerCount()).isEqualTo(1);
assertThat(booked.capacityCbm()).isEqualByComparingTo("68");
assertThat(booked.availableCapacityCbm()).isZero();
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isEqualTo(1);
jdbc.update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH'");
session.clearCache();
assertThat(mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00")).availableCapacityCbm()).isEqualByComparingTo("68");
}
/** 同柜多票只扣一次柜容量,多个柜型需求按各自容量独立扣减。 */
@Test
void subtractsEachDepartedContainerOnceFromItsOwnRequirement() {
booking(1, 2, "68");
jdbc.update("INSERT INTO fl_ocean_booking_container_requirement VALUES(1002,1000,1,58)");
jdbc.update("INSERT INTO fl_ocean_booking_container_allocation VALUES(1000,1001,100),(1000,1001,200),(1000,1002,300)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00'),(8,2,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00'),(7,3,200,'READY_FOR_DISPATCH',NULL),(7,4,300,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
var booked = mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00"));
assertThat(booked.containerCount()).isEqualTo(3);
assertThat(booked.capacityCbm()).isEqualByComparingTo("194");
assertThat(booked.availableCapacityCbm()).isEqualByComparingTo("68");
}
/** 已确认后班的货保留在后班,只有未安排余量进入前班,避免重复订柜。 */
@Test
void preservesConfirmedSailingAndOnlyRollsTheUnallocatedRemainder() {
booking(2, 1, "68");
inbound(1, "100", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("32");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isEqualTo(1);
assertThat(advice(2, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
// 全票已排柜时,前班不再产生任何补柜建议。
jdbc.update("UPDATE fl_inbound_order SET actual_volume_cbm=68,total_package_count=6800 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET total_volume_cbm=68,package_count=6800 WHERE measurement_id=10");
session.clearCache();
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
jdbc.update("UPDATE fl_sailing SET status='CLOSED' WHERE id=2");
session.clearCache();
assertThat(summary(2, "2026-11-03T19:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 原安排取消后只顺延未离仓部分;已有实际离仓事实不会再次进入待发池。 */
@Test
void rollsInvalidatedBookingsWithoutRestoringDepartedCargo() {
booking(2, 1, "68");
inbound(1, "100", "LOADED", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_sailing SET status='CANCELLED' WHERE id=2");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("32");
}
/** 显式关联优先于运输快照匹配,未关联时同名船期也只确定一班。 */
@Test
void resolvesOneBookingTargetAndHonorsAnExplicitLink() {
booking(2, 1, "68");
inbound(1, "68", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_ocean_booking SET vessel_name='VESSEL',voyage_no='VOYAGE-1',estimated_sailing_at='2026-10-28 10:00:00'");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
jdbc.update("UPDATE fl_sailing_booking_link SET link_status='REVOKED'");
sailing(3, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
jdbc.update("UPDATE fl_sailing SET voyage_no='VOYAGE-1' WHERE id=3");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
assertThat(summary(3, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(mapper.bookingSummary(3, LocalDateTime.parse("2026-10-27T00:00:00")).containerCount()).isZero();
}
/** 跨表文本转换必须保留完整编码与中文船名,不能将同前缀的运输信息视为同一船期。 */
@ParameterizedTest
@CsvSource({"channel_code,MATSON-OTHER", "departure_port_code,CNSHA", "destination_port_code,USLAX",
"vessel_name,远洋轮乙", "voyage_no,VOYAGE-OTHER"})
void matchesCompleteTransportSnapshotsAfterTextConversion(String column, String differentValue) {
booking(1, 1, "68");
jdbc.update("UPDATE fl_sailing_booking_link SET link_status='REVOKED'");
jdbc.update("UPDATE fl_sailing SET vessel_name='远洋轮甲' WHERE id=1");
jdbc.update("UPDATE fl_ocean_booking SET vessel_name='远洋轮甲',voyage_no='VOYAGE-1',estimated_sailing_at='2026-10-28 10:00:00'");
var today = LocalDateTime.parse("2026-10-27T00:00:00");
assertThat(mapper.bookingSummary(1, today).containerCount()).isEqualTo(1);
jdbc.update("UPDATE fl_ocean_booking SET " + column + "=?", differentValue);
session.clearCache();
assertThat(mapper.bookingSummary(1, today).containerCount()).isZero();
}
/** 顺延按完整渠道编码匹配,排除只有前缀相同的更早船期。 */
@Test
void rollsCargoOnlyToTheCompleteChannelCode() {
inbound(1, "68", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_sailing SET channel_code_snapshot='MATSON-OTHER' WHERE id=1");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 人工跨仓、跨渠道排柜已有正式订舱时,应跟随真实安排展示。 */
@Test
void preservesCrossWarehouseAndChannelAssignments() {
booking(2, 1, "68");
inbound(1, "68", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_inbound_order SET warehouse_id=99,channel_code='OTHER'");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 查看后月或筛选后班时仍返回前班截仓节点,仓库和渠道范围继续生效。 */
@Test
void findsRefreshBoundaryOutsideTheDisplayedMonthAndKeyword() {
var query = new com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery("2026-11", 8L, null, "MATSON", "OTHER", "OTHER", "SL-2", "CLOSED");
var now = LocalDateTime.parse("2026-10-27T12:00:00");
assertThat(mapper.nextCutoff(query, now, now.toLocalDate().atStartOfDay())).isEqualTo("2026-10-27T18:00:00");
assertThat(mapper.nextCutoff(query, LocalDateTime.parse("2026-10-27T18:00:01"), now.toLocalDate().atStartOfDay())).isEqualTo("2026-11-03T18:00:00");
var otherWarehouse = new com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery("2026-11", 99L, null, "MATSON", null, null, null, null);
assertThat(mapper.nextCutoff(otherWarehouse, now, now.toLocalDate().atStartOfDay())).isNull();
}
/** 建立一条已放舱订舱及唯一显式船期关联。 */
private void booking(long sailingId, int quantity, String capacity) {
jdbc.update("INSERT INTO fl_ocean_booking VALUES(1000,'RELEASED',8,'MATSON','CNNGB','USLGB',NULL,NULL,NULL)");
jdbc.update("INSERT INTO fl_ocean_booking_container_requirement VALUES(1001,1000,?,?)", quantity, new java.math.BigDecimal(capacity));
jdbc.update("INSERT INTO fl_sailing_booking_link VALUES(?,1000,'ACTIVE')", sailingId);
}
/** 按真实柜次关联订舱需求,并记录对应入库体积。 */
private void allocate(long inboundId, long containerId, String volume) {
jdbc.update("INSERT INTO fl_loading_plan VALUES(?,?)", containerId, containerId);
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(?,?,7,?,0,?,?)", containerId, containerId, inboundId, new java.math.BigDecimal(volume), new java.math.BigDecimal(volume).movePointRight(2).intValueExact());
jdbc.update("INSERT INTO fl_ocean_booking_container_allocation VALUES(1000,1001,?)", containerId);
}
/** 将实际 Mapper 的货量与容量投影送入生产订柜算法,验证组合场景。 */
private com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.BookingAdvice advice(long id, String time) {
var now = LocalDateTime.parse(time);
var repository = org.mockito.Mockito.mock(com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository.class);
var sailing = new com.baiyue.firstleg.sailingcalendar.entity.SailingEntity();
sailing.setId(id);
org.mockito.Mockito.when(repository.sailing(id)).thenReturn(sailing);
org.mockito.Mockito.when(repository.receiptSummary(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(summary(id, time));
org.mockito.Mockito.when(repository.bookingSummary(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(mapper.bookingSummary(id, now.toLocalDate().atStartOfDay()));
var service = new com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "repository", repository);
return service.bookingAdvice(id);
}
/** 构造同仓同渠道的船期,只使用公开业务编号排序。 */
private void sailing(long id, String etd, String cutoff, String status) {
jdbc.update("INSERT INTO fl_sailing VALUES(?, ?, 8, 'MATSON', ?, ?, ?, 'CNNGB', 'USLGB', 'VESSEL', ?)", id, "SL-" + id, status,
LocalDateTime.parse(etd), LocalDateTime.parse(cutoff), "VOYAGE-" + id);
}
/** 构造上月遗留的审核实测货量。 */
private void inbound(long id, String volume, String status, String splitStatus) {
var measuredVolume = new java.math.BigDecimal(volume);
int boxes = measuredVolume.movePointRight(2).intValueExact();
jdbc.update("INSERT INTO fl_inbound_order VALUES(?,7,0,'APPROVED',?,8,'MATSON',?,?,'2026-09-01 10:00:00',NULL,NULL,?)",
id, status, splitStatus, measuredVolume, boxes);
jdbc.update("INSERT INTO fl_inbound_measurement VALUES(?,7,?,'APPROVED')", id * 10, id);
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(?,7,?,1,?,?)", id * 10, id * 10, boxes, measuredVolume);
}
/** 用上海业务时间调用生产 SQL,读取目标船期自己的截止时间。 */
private SailingCalendarDisplayMapper.ReceiptSummary summary(long id, String time) {
LocalDateTime now = LocalDateTime.parse(time);
LocalDateTime cutoff = jdbc.queryForObject("SELECT warehouse_cutoff_at FROM fl_sailing WHERE id=?", LocalDateTime.class, id);
return mapper.receiptSummary(id, 8L, "MATSON", cutoff, now, now.toLocalDate().atStartOfDay());
}
}
@@ -2,16 +2,24 @@ package com.baiyue.firstleg.sailingcalendar.service;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.CopyRequest; import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.CopyRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity; import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository; import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
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.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.MockitoAnnotations; import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.List; import java.util.List;
@@ -22,6 +30,8 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.mock;
class SailingCalendarServiceTest { class SailingCalendarServiceTest {
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents(); @org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
@@ -31,10 +41,13 @@ class SailingCalendarServiceTest {
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14); private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
@BeforeEach @BeforeEach
/** 固定上海跨日边界,避免测试依赖运行机日期和时区。 */
void setUp() { void setUp() {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "clock", Clock.fixed(Instant.parse("2026-10-26T16:00:00Z"), ZoneId.of("UTC")));
} }
/** 复制幂等回放保留原有船期,只重新读取当前展示汇总。 */
@Test @Test
void replaysAnExistingCopyInsteadOfReturningAnInternalError() { void replaysAnExistingCopyInsteadOfReturningAnInternalError() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7)); SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
@@ -45,8 +58,8 @@ class SailingCalendarServiceTest {
when(repository.sailing(2L)).thenReturn(existing); when(repository.sailing(2L)).thenReturn(existing);
when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing); when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing);
when(repository.histories(2L)).thenReturn(List.of(history)); when(repository.histories(2L)).thenReturn(List.of(history));
when(repository.receiptSummary(any(SailingEntity.class))).thenReturn(null); when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenReturn(null);
when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null); when(repository.bookingSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenReturn(null);
var result = service.copy(1L, 7L, "admin", var result = service.copy(1L, 7L, "admin",
new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3))); new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3)));
@@ -81,6 +94,117 @@ class SailingCalendarServiceTest {
verify(repository, never()).insert(any(SailingEntity.class)); verify(repository, never()).insert(any(SailingEntity.class));
} }
/** 各柜型独立向上取整,正数不足一柜也应给出一柜建议。 */
@ParameterizedTest
@CsvSource({"0,0,0,0", "0.001,1,1,1", "58,1,1,1", "58.001,2,1,1", "68,2,1,1",
"68.001,2,2,1", "86,2,2,1", "86.001,2,2,2", "113.74,2,2,2"})
void roundsAllReceivedVolumeUpForEachContainerType(String volume, int gp, int hq, int largeHq) {
SailingEntity sailing = sailing(1L, "SL-ADVICE", "MANUAL", "TEST", copiedDeparture);
when(repository.sailing(1L)).thenReturn(sailing);
// 复现截图:全部实收都被标为晚到,不能因此得到零柜。
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(BigDecimal.ZERO, new BigDecimal(volume), 1));
var advice = service.bookingAdvice(1L);
var summary = service.volumeSummary(1L).summary();
assertThat(advice.actualReceivedCbm()).isEqualByComparingTo(volume);
assertThat(advice.catchableTotalCbm()).isZero();
assertThat(advice.lateReceivedCbm()).isEqualByComparingTo(volume);
assertThat(advice.recommendedContainerCount()).isEqualTo(hq);
assertThat(advice.pendingContainerCount()).isEqualTo(hq);
assertThat(summary.recommendedContainerCount()).isEqualTo(hq);
assertThat(summary.pendingContainerCount()).isEqualTo(hq);
assertThat(advice.scenarios()).extracting(s -> s.recommendedContainerCount()).containsExactly(gp, hq, largeHq);
assertThat(advice.scenarios()).extracting(s -> s.pendingContainerCount()).containsExactly(gp, hq, largeHq);
}
/** 待补柜扣除已订实际容量,而不是简单扣除已订柜数,超额容量不产生负数。 */
@ParameterizedTest
@CsvSource({"1,58,2,2,1", "2,126,1,1,1", "2,136,0,0,0", "3,204,0,0,0"})
void subtractsBookedCapacityBeforeRoundingTheShortage(int booked, String capacity, int gp, int hq, int largeHq) {
when(repository.sailing(1L)).thenReturn(sailing(1L, "SL-BOOKED", "MANUAL", "TEST", copiedDeparture));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(new BigDecimal("100"), new BigDecimal("36"), 1));
when(repository.bookingSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.BookingSummary(booked, new BigDecimal(capacity), new BigDecimal(capacity)));
var advice = service.bookingAdvice(1L);
assertThat(advice.recommendedContainerCount()).isEqualTo(2);
assertThat(advice.bookedContainerCount()).isEqualTo(booked);
assertThat(advice.pendingContainerCount()).isEqualTo(hq);
assertThat(service.volumeSummary(1L).summary().pendingContainerCount()).isEqualTo(hq);
assertThat(advice.scenarios()).extracting(s -> s.pendingContainerCount()).containsExactly(gp, hq, largeHq);
}
/** 上海今日零点前过期,今日整天及未来月份仍可参与列表和汇总。 */
@ParameterizedTest
@CsvSource({"2026-10,2026-10-27,2026-11-01", "2026-11,2026-11-01,2026-12-01"})
void limitsCalendarAndSummaryToTheUpcomingWindow(String month, LocalDate from, LocalDate to) {
SailingQuery query = new SailingQuery(month, null, null, null, null, null, null, null);
SailingEntity first = sailing(1L, "SL-TODAY", "MANUAL", "TEST", from.atStartOfDay());
when(repository.calendar(query, from.atStartOfDay(), to.atStartOfDay())).thenReturn(List.of(first));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(new BigDecimal("113.74"), BigDecimal.ZERO, 0));
var result = service.calendar(query);
verify(repository).calendar(query, from.atStartOfDay(), to.atStartOfDay());
assertThat(result.records()).extracting(r -> r.etdAt()).containsExactly(from.atStartOfDay());
assertThat(result.summary().sailingCount()).isEqualTo(1);
assertThat(result.summary().actualReceivedCbm()).isEqualByComparingTo("113.74");
assertThat(result.summary().recommendedContainerCount()).isEqualTo(2);
}
/** 过去月份不查询历史货量,也不把历史船期混入汇总。 */
@Test
void returnsAnEmptyCalendarForAnExpiredMonth() {
var result = service.calendar(new SailingQuery("2026-09", null, null, null, null, null, null, null));
assertThat(result.records()).isEmpty();
assertThat(result.summary().sailingCount()).isZero();
assertThat(result.summary().recommendedContainerCount()).isZero();
verifyNoInteractions(repository);
}
/** 刷新时间包括隐藏月份的前班截仓节点,并以上海零点为最长等待边界。 */
@ParameterizedTest
@CsvSource({"2026-10-27T00:00:00,2026-10-27T00:00:00.001", "2026-10-27T18:00:00,2026-10-27T18:00:00.001", "2026-11-03T18:00:00,2026-10-28T00:00:00"})
void schedulesRefreshUsingCrossMonthCutoff(String cutoff, String expectedRefresh) {
SailingQuery query = new SailingQuery("2026-11", null, null, null, null, null, "后班", null);
when(repository.nextCutoff(query, LocalDateTime.parse("2026-10-27T00:00:00")))
.thenReturn(LocalDateTime.parse(cutoff));
assertThat(service.calendar(query).nextRefreshAt()).isEqualTo(LocalDateTime.parse(expectedRefresh));
}
/** 即使系统时钟跨过截仓瞬间,同一次月历的所有船期也必须共享同一投影时刻。 */
@Test
void sharesOneSnapshotTimeAcrossCalendarRows() {
Clock movingClock = mock(Clock.class);
when(movingClock.withZone(any(ZoneId.class))).thenReturn(movingClock);
when(movingClock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai"));
when(movingClock.instant()).thenReturn(Instant.parse("2026-10-27T10:00:00Z"), Instant.parse("2026-10-27T10:00:01Z"));
ReflectionTestUtils.setField(service, "clock", movingClock);
SailingQuery query = new SailingQuery("2026-10", null, null, null, null, null, null, null);
SailingEntity first = sailing(1L, "SL-FIRST", "MANUAL", "TEST-1", LocalDateTime.parse("2026-10-28T10:00:00"));
SailingEntity second = sailing(2L, "SL-SECOND", "MANUAL", "TEST-2", LocalDateTime.parse("2026-10-30T10:00:00"));
LocalDateTime cutoff = LocalDateTime.parse("2026-10-27T18:00:00");
when(repository.calendar(any(SailingQuery.class), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(first, second));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenAnswer(call -> {
SailingEntity sailing = call.getArgument(0);
LocalDateTime asOf = call.getArgument(1);
boolean counted = asOf.isAfter(cutoff) ? sailing.getId() == 2L : sailing.getId() == 1L;
return new SailingCalendarDisplayMapper.ReceiptSummary(counted ? BigDecimal.TEN : BigDecimal.ZERO, BigDecimal.ZERO, 0);
});
var result = service.calendar(query);
assertThat(result.summary().actualReceivedCbm()).isEqualByComparingTo("10");
assertThat(result.records()).extracting(row -> row.volumeSummary().snapshotAt()).containsExactly(cutoff, cutoff);
verify(movingClock).instant();
}
/** 构造具备稳定业务编号的船期样本。 */
private static SailingEntity sailing(long id, String sailingNo, String sourceType, String voyageNo, LocalDateTime etdAt) { private static SailingEntity sailing(long id, String sailingNo, String sourceType, String voyageNo, LocalDateTime etdAt) {
SailingEntity value = new SailingEntity(); SailingEntity value = new SailingEntity();
value.setId(id); value.setSailingNo(sailingNo); value.setSourceType(sourceType); value.setVoyageNo(voyageNo); value.setId(id); value.setSailingNo(sailingNo); value.setSourceType(sourceType); value.setVoyageNo(voyageNo);
+2 -2
View File
@@ -136,7 +136,7 @@
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 - 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 - 海外采购按 `procurement-workflow.md` 的七状态流程执行:创建待审核,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
@@ -169,5 +169,5 @@
### 海外人工收费首版切换 ### 海外人工收费首版切换
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。 - 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购保留独立收费流程,禁止进入人工通用收费。 - 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。 - 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
@@ -38,10 +38,27 @@
- 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。 - 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。
- 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。 - 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。
- 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。 - 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。
- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql) 仅解决自动仓租双零金额和空汇率入费用源表,保留人工正额与正常费用有效汇率约束,不更新业务数据。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。 - 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。指定 TEST 先使用[表与审核人字段补齐](../../deploy/sql/overseas-fees-storage-rent-structure-add.sql),再使用[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql);精确范围与失败语义只维护在[数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。
- 新空库:Lastleg 源基线与海外统一初始化脚本同步新表和字段;国内统一基线同步菜单。菜单查询权限 `platform:overseas-fee:list`,编辑与审核分别沿用 `platform:finance:fee-source:edit/review`,仅有查询权限不能写费用。 - 新空库:Lastleg 源基线与海外统一初始化脚本同步新表和字段;国内统一基线同步菜单。菜单查询权限 `platform:overseas-fee:list`,编辑与审核分别沿用 `platform:finance:fee-source:edit/review`,仅有查询权限不能写费用。
- 配置:核对 Firstleg、Warehouse 内部服务地址及内部令牌、客户仓租阶梯、有效 USD/CNY 月度汇率、Finance 资金与费用事件直投及回执配置。当前结算支持 USD、CNY,CNY 按1:1结算,其他币种必须先调整为受支持币种。不得在材料中记录密钥。 - 配置:核对 Firstleg、Warehouse 内部服务地址及内部令牌、客户仓租阶梯、有效 USD/CNY 月度汇率、Finance 资金与费用事件直投及回执配置。当前结算支持 USD、CNY,CNY 按1:1结算,其他币种必须先调整为受支持币种。不得在材料中记录密钥。
- 发布顺序:先发布 Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。 - 发布顺序:先发布 Firstleg 公共费用目录、Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。
### TEST 配套发布清单
本清单用于准备和审阅,不表示环境已完成执行。实际版本、部署结果及未关闭验收项仍只维护于[测试环境主契约](../../../docs/测试环境单机部署与配置契约.md)。发布前冻结后端、Admin、海外 PDA 的精确提交和产物摘要;执行期间任一版本变化都需重新核对受影响项,不使用浮动分支头替代审阅版本。
| 顺序 | 对象 | 执行前提与通过标准 |
|---|---|---|
| 1 | 规则与执行窗口 | 确认人工应收是否开放“推送结算/余额扣费”选择:当前前后端已支持显式选择,未选择才按业务默认规则;本文件首版固定分流描述不能作为该选择功能已获确认的依据。明确本批 TEST SQL、服务发布及测试客户写入授权,切换期间暂停相关收费与出库操作 |
| 2 | Lastleg 结构 | 按数据库变更汇总审阅并执行两个定向文件,核对新增表/字段及免费仓租约束;不重新初始化已有资金、计租表,不重放已有权限种子,不改现行汇率 |
| 3 | Firstleg、Warehouse、Finance | 用匹配后端提交分别发布,核对容器镜像与健康;Firstleg 具备 `/internal/firstleg/v1/fee-items`,Warehouse 具备客户计租 `/pricing`,Finance 接受应收结算方式及独立扣款引用;内部服务地址、统一服务令牌及事件回传配置匹配 |
| 4 | Lastleg | 依赖就绪后发布费用/仓租编排及收发货挂钩;统一 Gateway 下费用分页、项目目录、仓租列表与仓库选项均返回业务成功,不能只检查 HTTP 200 |
| 5 | Admin、海外 PDA | Admin 包含海外费用管理和单据费用操作;海外 PDA 包含 `/pda/v1/last-leg/storage-rent/preparations` 出库预校验。H5 按宿主机静态站点规则发布并核验资源摘要;APK/WGT 按实际设备另行核对,H5 通过不等于设备已更新 |
| 6 | 最小业务验收 | 使用明确授权的新测试样本完成下表;逐笔核对业务单、费用、冻结/消费/退款流水及事件回执。查询成功、表为空或历史事件已投递不能替代新功能闭环 |
第一轮最小样本按以下顺序执行:人工余额收费的登记冻结、审核实扣、原请求重放与驳回释放;账单收费审核入池且钱包不变;免费仓租记录无冻结/扣款;非零仓租出库预占、审核前调价及审核实扣;头程同单同仓补入库不重置库龄、重复实收不增加计租数量。供应商账期/直付与采购专用资金链继续按下表分别验收,不由人工收费样本推定通过。
若结构、服务、页面或设备任一项不匹配,保持相关收费/出库操作暂停并记录失败,不通过补零、伪造回执或重建费用继续;DDL 不做自动回滚,服务回滚仅限明确核对过的同协议同 Schema 版本。页面布局由人工按 1920×1080、100% 缩放提供截图核对。
## 实际环境验收 ## 实际环境验收
@@ -65,7 +82,7 @@
## 验证记录 ## 验证记录
本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调与 PDA 设备验收尚未执行。上线前逐项填写环境、版本、执行时间与证据,未通过项不得标成通过。 本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调及 PDA 验收状态统一维护在[测试环境单机部署与配置契约](../../../docs/测试环境单机部署与配置契约.md)。该主契约逐项记录环境、版本、执行时间与证据,未通过项不得标成通过。
2026-10-22 本地验证记录(使用最终受影响文件;复用已通过且输入未变的结果): 2026-10-22 本地验证记录(使用最终受影响文件;复用已通过且输入未变的结果):
@@ -1,5 +1,7 @@
# 采购双方向履约 # 采购双方向履约
> 2026-10-27 起,日常操作与验收以 [采购简化流程](procurement-workflow.md) 为准。下文保留方向字段与原两段样品链的设计记录,旧装箱、面单、两段核费不再作为新流程门禁。
2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。 2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。
- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;平台审核仅展示客户下单方向,不允许审核改向;采购仓按该方向筛选。审核底部直接取消、拒绝或通过,拒绝必须填写具体原因。 - `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;平台审核仅展示客户下单方向,不允许审核改向;采购仓按该方向筛选。审核底部直接取消、拒绝或通过,拒绝必须填写具体原因。
@@ -0,0 +1,66 @@
# 海外采购旧流程表退役
2026-10-27 已完成代码分离。只处理海外采购专属模型和业务表,不删除或更新共用财务、资金、File、仓储库存或其他海外业务表。尚未执行目标数据库,也未部署运行服务。
采购专属表从 27 张收敛为 6 张;21 张旧表已从生产 Java 和两份初始化基线解除引用。原只读核查发现的阻塞现已在本次修改中处理,不能在旧版服务上直接执行删表脚本。
## 保留六表
| 表 | 用途 |
|---|---|
| ll_overseas_procurement_order | 采购主单、方向、审核仓库与当前状态 |
| ll_overseas_procurement_status_history | 创建、审核及备注修改流水 |
| ll_overseas_procurement_file | 收货、签收附件归属 |
| ll_procurement_workflow_receipt | 分批实收及照片 |
| ll_procurement_workflow_outbound | 分批出库、物流和签收 |
| ll_procurement_workflow_command | 批次操作幂等及审计 |
## 精确退役清单(21 张)
- `ll_overseas_procurement_fee`
- `ll_procurement_wallet_attempt`
- `ll_procurement_flow`
- `ll_procurement_command`
- `ll_procurement_budget_authorization`
- `ll_procurement_receipt`
- `ll_procurement_disposition`
- `ll_procurement_manifest`
- `ll_procurement_carton`
- `ll_procurement_carton_source`
- `ll_procurement_shipment`
- `ll_procurement_shipment_item`
- `ll_procurement_label_set`
- `ll_procurement_label_item`
- `ll_procurement_estimate_review`
- `ll_procurement_repack`
- `ll_procurement_fee_correction`
- `ll_procurement_fee_correction_item`
- `ll_procurement_control_note`
- `ll_procurement_closure`
- `ll_procurement_closure_item`
## 代码分离
- ProcurementQueryService 只读取主单、附件、审核流水及共用人工海外费用,不再执行旧预算、装箱、面单、运输、异常和采购费用查询。客户投影仍过滤客户、主单、业务类型和应收方向,内部说明不公开。
- 创建、审核、编辑响应不再访问旧采购费用表;仓库直接取审核主单。原响应中的旧区块保留空容器,避免当前前端适配器访问缺失数组,背后没有旧业务表依赖。
- 旧购买、预算核验、物理装箱、运输面单、费用核定及异常处置 Controller/Service/DTO 已删除。新批次接口和独立上传、客户确认、文件权限保持不变。
- 旧采购专属 WalletResultHandler、费用 Mapper/Entity、方向事实 Mapper 和专属退款编号 Repository 已移除;共用资金消费者、Outbox/Inbox 和共用海外费用审核链不修改。
- 共用费用业务白名单仅移除已退役 PROCUREMENT_FEE 子业务实体查表项,OVERSEAS_PROCUREMENT 主单继续沿原共用应收、冻结、审核和实扣链。
- 旧履约 OpenAPI 仅保留当前文件接口与详情投影;七状态流程独立契约继续保留。旧实现测试随旧功能退役;现行分批数量、库存、客户隔离、幂等、费用门禁与权限测试保留,并新增仅六表可运行的真实 Mapper 详情/审核测试和退役范围合同。
## 脚本与执行顺序
1. [只读核查 SQL](../../deploy/sql/overseas-procurement-table-cleanup-review.sql):查看 27 张表存在状态及行数估算,支持重复执行,不需要业务写权限。
2. 完成快照/备份,停止旧服务写入并部署本次 Lastleg 分离版本;在维护窗口验证现行主流程。必须先部署、再删除旧表。不能把脚本存在视为已发布。
3. 审阅 [精确删表 SQL](../../deploy/sql/overseas-procurement-legacy-tables-retire.sql)。在指定 MySQL 8.0+ 实例的同一连接设置脚本声明的发布确认变量,然后整份执行,禁止遇错继续。脚本只对 baiyue_lastleg 的 21 张清单表执行一条 DROP TABLE IF EXISTS;DDL 隐式提交,不承诺事务回滚。
4. 脚本先验证六张当前表存在、无旧状态采购、旧资金表无任何记录,并只读检查共用资金 Outbox/Inbox 中是否仍有 PROCUREMENT_FEE 证据。有旧资金记录就停止,不自动删除、迁移或伪造结清,也不修改共用表。非空旧物理/运输/异常表会随 DROP 永久删除,须在执行前核对备份及范围。
5. 前置检查在首个业务 DROP 前完成;不关闭外键校验,有外部引用则失败。完整成功后可重复执行,已删除表按 IF EXISTS 跳过。失败时停止、保留原错误并核查现状;最终剩余旧表数应为 0,同时确认客户端退出码为 0。
只读脚本不证明数据为空,删除脚本也不能自动识别运行中的二进制版本。V14—V21 旧采购迁移仅用于历史追溯,不得在本次退役后重放;当前 Lastleg 基线与海外空库基线已删除这 21 张定义。不得用本脚本删除六张现用表或任何共用费用、余额、文件对象及库存数据。
## 本次验证
- 后端现行采购及退役契约 55 项、管理端相关 67 项、Gateway 路由 5 项和脚本文档契约 4 项通过;Mapper 扫描通过。
- 两份初始化 SQL 与修改前逐表比较:均只移除这 21 张表,其余表定义完全一致;生产 Java 中没有这 21 张旧表名引用。
- Lastleg 架构检查 15 项中 14 项通过,无新增跨业务依赖;车队检查仍因 dispatch 访问 fleet 持久化 Mapper 失败,该问题不在本次采购修改范围。
- 未执行目标 MySQL、未部署、未进行线上页面验收;SQL 仅完成静态范围及防护契约检查。
@@ -0,0 +1,48 @@
# 海外采购简化流程
2026-10-27 按客户确认的需求收敛采购主流程,适用于海外采购回国内、国内采购发往海外两个方向。
| 主状态 | 编码 | 操作 |
|---|---|---|
| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 |
| 收货中 | `RECEIVING` | 平台分批登记数量和照片 |
| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 |
| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 |
| 运输中 | `IN_TRANSIT` | 平台从采购单创建出库批次,填写数量和物流跟踪号 |
| 派送中 | `DELIVERING` | 平台更新批次派送状态 |
| 签收完成 | `COMPLETED` | 全部采购库存已出完,且所有出库批次已签收 |
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。每次创建出库批次前,本采购主单必须至少有一项未作废应收费用,且全部未作废应收费用均须有效并审核通过;缺少费用、待审核或驳回时提示并阻止出库,前后端共同校验。应付与已作废费用不能替代应收费用明细;原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。
## 验收范围
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
- 创建采购单后为待审核,审核后直接收货中。
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。
- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。
- 无费用、仅作废费用、仅应付费用或任一应收尚未审核通过时,不能创建出库;全部有效应收审核通过后允许出库。
自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。
## 2026-10-27 本地验证记录
- 平台端定向检查104项通过,客户端定向检查28项通过;两端 TypeScript 检查通过。
- 后端七状态、审核、权限、OpenAPI、结构中文备注40项去重定向检查通过;共用费用服务44项、采购费用来源4项、客户详情投影7项、费用真实Mapper持久化11项通过。
- 流程测试使用隔离内存数据库和真实MyBatis Mapper,覆盖两次收货、客户确认、两批出库、派送及签收扣减,以及超量、并发、重试、版本与文件归属。
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。
@@ -1431,7 +1431,8 @@ public class DeliveryTransferService {
} }
/** 派送动作由状态和实际出库共同决定,列表与详情复用同一资格。 */ /** 派送动作由状态和实际出库共同决定,列表与详情复用同一资格。 */
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus(), deliveryExecution.outboundCompleted(d)), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus(), deliveryExecution.outboundCompleted(d)), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); } /** 派送费用说明沿用收费事实,不以审核意见覆盖业务说明。 */
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getFeeRemark(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
private String deliveryGroupKey(DispatchItem item) { private String deliveryGroupKey(DispatchItem item) {
@@ -32,8 +32,8 @@ public class DispatchLoadingDocumentController {
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot, public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { @RequestParam @Positive long reviewRound,@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file)); return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,reviewRound,idempotencyKey,file));
} }
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
@PutMapping("/{slot}") @PutMapping("/{slot}")
@@ -32,8 +32,8 @@ public class PdaDispatchLoadingDocumentController {
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot, public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { @RequestParam @Positive long reviewRound,@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file)); return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,reviewRound,idempotencyKey,file));
} }
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
@PutMapping("/{slot}") @PutMapping("/{slot}")
@@ -373,6 +373,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
public CommandResult cancel(Actor actor,String number,DispatchCommands.ReasonCommand request) { public CommandResult cancel(Actor actor,String number,DispatchCommands.ReasonCommand request) {
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request); var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request);
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion());
if("LOCAL_TRUCK".equals(row.getTaskType()) && !"BEFORE_LOADING".equals(row.getStage()))throw conflict("装车中及后续阶段不能取消排车");
if(trim(request.reason())==null)throw bad("取消原因不能为空"); if(trim(request.reason())==null)throw bad("取消原因不能为空");
if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消"); if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消");
// 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。 // 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。
@@ -452,6 +453,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) { if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id); if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
} }
masterPhotos.invalidateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class),savedResources);
row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
// 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。 // 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。
@@ -468,7 +470,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
requireEditing(row,request.recordVersion()); requireEditing(row,request.recordVersion());
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
verifyReviewResources(row); verifyReviewResources(row);
if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核"); // 本土卡车货物来自原卡车订单,不建立 dispatch_item;按排车摘要件数即可进入两级审核。
if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && !"LOCAL_TRUCK".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row); long round=row.getReviewRound()+1;String basis=basis(row);
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null))); row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null)));
@@ -504,7 +507,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments()); var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments());
if(loading) { if(loading) {
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name()); if(request.decision()==Decision.APPROVED) {
row.setStage(Stage.LOADING.name());
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class));
}
} else { } else {
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound()))); row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound())));
@@ -696,10 +702,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */ /** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); } private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); }
/** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */ /** 取货及本土卡车锁原车次,仓库派送锁整车资源;统一先父后主单。 */
private void lockReviewRoot(Actor actor,String number) { private void lockReviewRoot(Actor actor,String number) {
var row=required(actor,number,false); var row=required(actor,number,false);
if("PICKUP".equals(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number); if(Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number);
} }
/** 严格请求身份不接收前端摘要或操作者。 */ /** 严格请求身份不接收前端摘要或操作者。 */
private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));} private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));}
@@ -718,7 +724,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
private String basis(DispatchAggregate row) { private String basis(DispatchAggregate row) {
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt()); facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson()); if(Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
else { else {
var batches=json.valueToTree(repository.batches(row.getId())); var batches=json.valueToTree(repository.batches(row.getId()));
// 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。 // 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。
@@ -786,6 +792,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
void verifyReviewResources(DispatchAggregate row) { void verifyReviewResources(DispatchAggregate row) {
if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;} if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;}
if("LOCAL_TRUCK".equals(row.getTaskType())) {transportTasks.verifyLocalTruckReview(row.getDispatchNo());return;}
requireReviewAppointment(row); requireReviewAppointment(row);
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话"); if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
@@ -919,12 +926,25 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
d.status(),d.packageCount(),d.palletCount())).toList(); d.status(),d.packageCount(),d.palletCount())).toList();
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
// 本土卡车路线以运输资源中的真实首末站为准,兼容早期只保存仓库编码的排车主单。
String route=row.getRoute();
if("LOCAL_TRUCK".equals(row.getTaskType())) route=localTruckRoute(number, route);
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver(),resources.maintenance()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver(),resources.maintenance()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)), new DispatchViews.DispatchInfo(route,instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand())); documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand()));
} }
/** 本土卡车通过运输任务关联读取真实首末站,不能走仓库派送的整车绑定查询。 */
private String localTruckRoute(String dispatchNo, String fallback) {
try {
var detail=transportTasks.detail(dispatchNo);
var stops=detail.execution().trip().stops();
if(stops!=null && stops.size()>=2) return stops.get(0).name()+" → "+stops.get(stops.size()-1).name();
} catch (RuntimeException ignored) { }
return fallback;
}
/** 恢复动作结果前重新核验当前单据范围和原动作权限。 */ /** 恢复动作结果前重新核验当前单据范围和原动作权限。 */
@Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED) @Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED)
public CommandResult command(Actor actor,String number,String key) { public CommandResult command(Actor actor,String number,String key) {
@@ -1032,9 +1052,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
var blocked=new EnumMap<Action,Gate>(Action.class); var blocked=new EnumMap<Action,Gate>(Action.class);
boolean pickup="PICKUP".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus()); boolean pickup="PICKUP".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus());
boolean localTruck="LOCAL_TRUCK".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus());
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) { for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) {
boolean state=pickup && switch(action) { boolean state=(pickup || localTruck) && switch(action) {
case EDIT,CANCEL -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()); case EDIT -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage());
case CANCEL -> localTruck ? "BEFORE_LOADING".equals(row.getStage()) : Set.of("BEFORE_LOADING","LOADING").contains(row.getStage());
case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState()); case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState());
case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState()); case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState());
case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState()); case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState());
@@ -30,8 +30,11 @@ public class DispatchLoadingDocumentService {
require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true)); require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true));
} }
/** 上传先记原请求意图,不提前绑定或推进派送。 */ /** 上传先记原请求意图,不提前绑定或推进派送。 */
public Photo upload(Actor actor,String number,Slot slot,String key,MultipartFile file) { public Photo upload(Actor actor,String number,Slot slot,long reviewRound,String key,MultipartFile file) {
require(actor,true);var context=context(actor,number,false);return photos.upload(context.dispatchId(),actor.operatorId(),slot,key,file); require(actor,true);var context=context(actor,number,false);
if(reviewRound<=0 || reviewRound!=context.reviews().loadingReview().reviewRound())
throw new BizException(ResultCode.CONFLICT,"审核轮次已变化,请刷新后重新选择资料");
return photos.upload(context.dispatchId(),actor.operatorId(),slot,reviewRound,key,file);
} }
/** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */ /** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */
public Photo recover(Actor actor,String number,String key) { public Photo recover(Actor actor,String number,String key) {
@@ -42,7 +45,7 @@ public class DispatchLoadingDocumentService {
public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) { public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) {
require(actor,true);var context=context(actor,number,true); require(actor,true);var context=context(actor,number,true);
var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay; var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay;
var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,request); var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,context.reviews().loadingReview().reviewRound(),request);
if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) { if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) {
departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number); departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number);
} }
@@ -64,7 +67,7 @@ public class DispatchLoadingDocumentService {
} }
/** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */ /** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */
private Documents documents(Context context,State state,boolean editable) { private Documents documents(Context context,State state,boolean editable) {
return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),context.stage().name(),editable,complete(state.photos()),state.photos()); return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),Long.toString(context.reviews().loadingReview().reviewRound()),context.stage().name(),editable,complete(state.photos()),state.photos());
} }
/** 按五个固定框核齐,不能用总数或重复槽位代替。 */ /** 按五个固定框核齐,不能用总数或重复槽位代替。 */
static boolean complete(List<Photo> photos) { static boolean complete(List<Photo> photos) {
@@ -8,6 +8,11 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.*; import java.io.*;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.List; import java.util.List;
import java.util.ArrayList;
import java.util.Objects;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER; import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER;
@@ -16,10 +21,75 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER
public class DispatchMasterPhotoService { public class DispatchMasterPhotoService {
private final FleetResourcePhotoPort photos; private final FleetResourcePhotoPort photos;
private final DispatchEvidenceGateway evidence; private final DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private DispatchLoadingPhotoPort loadingPhotos;
@jakarta.annotation.Resource private FleetTripExecutionService fleet;
/** 只读主档并通过本车文件网关创建证据。 */ /** 只读主档并通过本车文件网关创建证据。 */
public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) { public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) {
this.photos=photos; this.evidence=evidence; this.photos=photos; this.evidence=evidence;
} }
/** 资源保存时比较本趟资料来源;来源变化后旧类别不能继续充当新资源的凭证。 */
public void invalidateLoading(long dispatchId,String operator,Resources before,Resources after) {
var slots=java.util.EnumSet.noneOf(Slot.class);
if(!Objects.equals(vehicleSource(before.vehicle()),vehicleSource(after.vehicle())))slots.add(Slot.LOADING_LIST);
if(!Objects.equals(driverSource(before.driver()),driverSource(after.driver())))slots.add(Slot.LOADING_SCENE);
if(!Objects.equals(vehicleIdentity(before.vehicle()),vehicleIdentity(after.vehicle()))) {
slots.add(Slot.PRE_DEPARTURE_CHECK);slots.add(Slot.DRIVER_LICENSE);
}
if(!Objects.equals(driverIdentity(before.driver()),driverIdentity(after.driver())))slots.add(Slot.RECEIPT);
if(!slots.isEmpty())loadingPhotos.invalidateResourcePhotos(dispatchId,operator,slots);
}
/** 换车必须重新记录装车和封车现场;同车补图不影响现场凭证。 */
private Object vehicleIdentity(VehicleSnapshot value) {
return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate());
}
/** 签字对应实际司机身份,不能在换司机后沿用。 */
private Object driverIdentity(DriverSnapshot value) {
return value==null?null:java.util.Arrays.asList(value.mode(),value.driverId(),value.name(),value.evidence()==null?null:value.evidence().licenseNo());
}
/** 只比较车辆身份及照片,不因容量或运营状态变更废弃现场资料。 */
private Object vehicleSource(VehicleSnapshot value) {
return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate(),sourceFiles(value.evidenceFileIds()));
}
/** 临时司机同样比较身份与证照;人像、电话和预计结束时间不属于驾驶证来源。 */
private Object driverSource(DriverSnapshot value) {
if(value==null)return null;
var evidence=value.evidence();
return java.util.Arrays.asList(value.mode(),value.driverId(),value.name(),evidence==null?null:evidence.licenseNo(),
evidence==null?null:sourceFiles(evidence.fileIds()),evidence==null?null:evidence.masterLicenseFileId());
}
/** 空附件与未提供附件等价,调整顺序不改变资料来源。 */
private java.util.Set<Long> sourceFiles(List<Long> ids) { return ids==null?java.util.Set.of():java.util.Set.copyOf(ids); }
/** 理货通过后把已审核的本趟快照复制进装车资料;不读取后来变更的主档、不复制司机人像。 */
public void populateLoading(long dispatchId,String operator,Resources value) {
var saved=loadingPhotos.read(dispatchId).photos();
var driver=value.driver();
if(driver!=null && driver.evidence()!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_SCENE)) {
var files=new ArrayList<MultipartFile>();
for(var id:driver.evidence().fileIds().stream().distinct().limit(5).toList()) {
if(driver.mode()==MASTER && driver.driverId()!=null && Objects.equals(id,driver.evidence().masterLicenseFileId())) {
var source=fleet.dispatchDriverLicense(driver.driverId(),id);
files.add(new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content()));
} else files.add(snapshot(dispatchId,DispatchEvidenceGateway.Kind.DRIVER,id));
}
loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_SCENE,files);
}
var vehicle=value.vehicle();
if(vehicle!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_LIST)) {
var files=new ArrayList<MultipartFile>();
for(var id:vehicle.evidenceFileIds().stream().distinct().toList()) {
var metadata=evidence.metadata(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id);
if(!List.of("image/jpeg","image/png").contains(metadata.contentType()))continue;
files.add(new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id)));
if(files.size()==5)break;
}
loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_LIST,files);
}
}
/** 复制前再次核验本趟及证据分类,不把外部文件编号直接绑定为装车资料。 */
private MultipartFile snapshot(long dispatchId,DispatchEvidenceGateway.Kind kind,long id) {
var metadata=evidence.metadata(dispatchId,kind,id);
return new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,kind,id));
}
/** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */ /** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */
public Resources populate(long dispatchId,String operator,Resources value) { public Resources populate(long dispatchId,String operator,Resources value) {
var v=value.vehicle(); var d=value.driver(); var v=value.vehicle(); var d=value.driver();
@@ -21,8 +21,8 @@ public interface TransportTaskMapper {
/** 保存首次本土卡车安排内容。 */ /** 保存首次本土卡车安排内容。 */
@Insert("INSERT INTO ll_dispatch_transport_create(trip_no,request_hash) VALUES(#{trip},#{hash})") @Insert("INSERT INTO ll_dispatch_transport_create(trip_no,request_hash) VALUES(#{trip},#{hash})")
int createHashSave(@Param("trip") String trip,@Param("hash") String hash); int createHashSave(@Param("trip") String trip,@Param("hash") String hash);
/** 仅返回已完成原单收货分配且没有承运安排的本土卡车需求。 */ /** 返回新流程待排车单,并兼容尚未迁移的历史托盘节点。 */
@Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status='PENDING_DISPATCH' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100") @Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PALLET_OVERAGE_REVIEW','PARTIALLY_ALLOCATED','ALLOCATED','PENDING_DISPATCH') AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100")
java.util.List<com.baiyue.lastleg.trucking.entity.UsTruckOrder> candidates(); java.util.List<com.baiyue.lastleg.trucking.entity.UsTruckOrder> candidates();
/** 原取货创建请求重放时恢复同一排车号。 */ /** 原取货创建请求重放时恢复同一排车号。 */
@Select("SELECT * FROM ll_dispatch_order WHERE trip_id=#{trip}") @Select("SELECT * FROM ll_dispatch_order WHERE trip_id=#{trip}")
@@ -18,6 +18,7 @@ public final class TransportTaskModels {
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { } @NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution, public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
String customerId,String orderNo,List<FleetPickupModels.Task> pickups, String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> departureEvidence,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence, List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { } Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { }
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator; import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity; import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.baiyue.lastleg.fleet.trip.FleetTripModels;
import com.baiyue.lastleg.fleet.trip.TransportTripChanged; import com.baiyue.lastleg.fleet.trip.TransportTripChanged;
import com.baiyue.lastleg.trucking.entity.*; import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
@@ -40,11 +41,19 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null,null); var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null,null);
row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review)); row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review));
resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt()); resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());
row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages); row.setRoute("LOCAL_TRUCK".equals(trip.getTripPurpose()) ? localTruckRoute(trip, items) : "到仓取货 · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages);
row.setPickedPackageCount(0);row.setShippedPackageCount(0);row.setRemark(trip.getRemark());row.setCreatedBy(operator); row.setPickedPackageCount(0);row.setShippedPackageCount(0);row.setRemark(trip.getRemark());row.setCreatedBy(operator);
row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());roots.insert(row); row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());roots.insert(row);
roots.history(IdWorker.getId(),row,"TRANSPORT_CREATE",operator,"接入现有排车",write(row),row.getUpdatedAt());return row; roots.history(IdWorker.getId(),row,"TRANSPORT_CREATE",operator,"接入现有排车",write(row),row.getUpdatedAt());return row;
} }
/** 本土卡车审核显示真实起运地和目的地,避免只显示仓库编码。 */
private String localTruckRoute(FleetTripEntity trip, int items) {
try {
var stops=json.readValue(trip.getStopsJson(), new com.fasterxml.jackson.core.type.TypeReference<List<FleetTripModels.Stop>>(){});
if(stops!=null && stops.size()>=2) return stops.get(0).name()+" → "+stops.get(stops.size()-1).name();
} catch(Exception ignored) { }
return "本土卡车 · "+items+"单 · "+trip.getWarehouseCode();
}
/** 与资源事实同事务更新主单,兼容仍被集成调用的旧执行接口。 */ /** 与资源事实同事务更新主单,兼容仍被集成调用的旧执行接口。 */
@EventListener @EventListener
public void changed(TransportTripChanged event) { public void changed(TransportTripChanged event) {
@@ -60,8 +69,9 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt()); row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt());
String oldResources=row.getResourcesJson();var oldDeparture=row.getPlannedDepartureAt();var oldArrival=row.getEstimatedArrivalAt(); String oldResources=row.getResourcesJson();var oldDeparture=row.getPlannedDepartureAt();var oldArrival=row.getEstimatedArrivalAt();
row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip); row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip);
if("LOCAL_TRUCK".equals(row.getTaskType())) row.setRoute(localTruckRoute(trip, Objects.requireNonNullElse(row.getItemCount(), 0)));
// 资源或计划改变后作废本轮审核,保留审核驳回原因供修改时核对。 // 资源或计划改变后作废本轮审核,保留审核驳回原因供修改时核对。
if("PICKUP".equals(row.getTaskType()) && "PLANNED".equals(trip.getStatus()) if(Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()) && "PLANNED".equals(trip.getStatus())
&& (!Objects.equals(oldResources,row.getResourcesJson()) || !Objects.equals(oldDeparture,row.getPlannedDepartureAt()) || !Objects.equals(oldArrival,row.getEstimatedArrivalAt()))) { && (!Objects.equals(oldResources,row.getResourcesJson()) || !Objects.equals(oldDeparture,row.getPlannedDepartureAt()) || !Objects.equals(oldArrival,row.getEstimatedArrivalAt()))) {
row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING"); row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound())); row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound()));
@@ -102,7 +112,7 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
public void transition(String customer,String number,String target,String action,String operator,String reason,String key) { public void transition(String customer,String number,String target,String action,String operator,String reason,String key) {
var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在"); var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在");
String from=order.getStatus();if(target.equals(from))return; String from=order.getStatus();if(target.equals(from))return;
boolean allowed=("PENDING_DISPATCH".equals(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target)); boolean allowed=(Set.of("WAITING_INBOUND","RECEIVING","WAITING_CONSOLIDATION","PALLET_OVERAGE_REVIEW","PARTIALLY_ALLOCATED","ALLOCATED","PENDING_DISPATCH").contains(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target));
if(!allowed)throw new IllegalStateException("本土卡车原单状态与排车执行不一致"); if(!allowed)throw new IllegalStateException("本土卡车原单状态与排车执行不一致");
order.setStatus(target);order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));trucks.updateOrder(order); order.setStatus(target);order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));trucks.updateOrder(order);
var history=new UsTruckOrderStatusHistory();history.setId(IdWorker.getId());history.setOrderId(order.getId());history.setCustomerId(customer); var history=new UsTruckOrderStatusHistory();history.setId(IdWorker.getId());history.setOrderId(order.getId());history.setCustomerId(customer);
@@ -22,6 +22,8 @@ import java.security.MessageDigest;
/** 现有排车入口编排运输任务,不调用仓内拣货、装车审核及扣库存命令。 */ /** 现有排车入口编排运输任务,不调用仓内拣货、装车审核及扣库存命令。 */
@Service @Service
public class TransportTaskService { public class TransportTaskService {
/** 新流程审核后直接待排车,旧托盘节点作为历史兼容状态一并放行到排车。 */
private static final Set<String> TRUCK_READY_STATUSES = Set.of("WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PALLET_OVERAGE_REVIEW", "PARTIALLY_ALLOCATED", "ALLOCATED", "PENDING_DISPATCH");
private final DispatchAggregateRepository roots; private final DispatchAggregateRepository roots;
private final TransportTaskRepository links; private final TransportTaskRepository links;
private final TransportTaskProjection projection; private final TransportTaskProjection projection;
@@ -77,7 +79,7 @@ public class TransportTaskService {
var order=trucks.lockNo(request.customerId(),request.orderNo());if(order==null)throw missing("本土卡车单不存在"); var order=trucks.lockNo(request.customerId(),request.orderNo());if(order==null)throw missing("本土卡车单不存在");
prior=tripRepo.findCreated(operator,request.idempotencyKey());if(prior!=null)return replayCreate(prior,hash); prior=tripRepo.findCreated(operator,request.idempotencyKey());if(prior!=null)return replayCreate(prior,hash);
pool.requireStandalone(order.getCustomerId(),"US_TRUCK",order.getId()); pool.requireStandalone(order.getCustomerId(),"US_TRUCK",order.getId());
if(!"PENDING_DISPATCH".equals(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待派车且未安排承运的本土卡车单可以接入排车"); if(!TRUCK_READY_STATUSES.contains(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待取货且未安排承运的本土卡车单可以接入排车");
if(order.getOriginAddress()==null || order.getOriginAddress().isBlank() || order.getDestinationAddress()==null || order.getDestinationAddress().isBlank())throw conflict("请先补齐原单起终点地址"); if(order.getOriginAddress()==null || order.getOriginAddress().isBlank() || order.getDestinationAddress()==null || order.getDestinationAddress().isBlank())throw conflict("请先补齐原单起终点地址");
var created=trips.createLocalTruck(operator,new FleetTripModels.Create(request.warehouseId(),request.plannedDepartureAt(),request.expectedDriverEndAt(), var created=trips.createLocalTruck(operator,new FleetTripModels.Create(request.warehouseId(),request.plannedDepartureAt(),request.expectedDriverEndAt(),
List.of(new FleetTripModels.Stop(order.getOrigin(),order.getOriginAddress(),null),new FleetTripModels.Stop(order.getDestination(),order.getDestinationAddress(),null)),"本土卡车:"+order.getOrderNo(),request.idempotencyKey())); List.of(new FleetTripModels.Stop(order.getOrigin(),order.getOriginAddress(),null),new FleetTripModels.Stop(order.getDestination(),order.getDestinationAddress(),null)),"本土卡车:"+order.getOrderNo(),request.idempotencyKey()));
@@ -95,15 +97,25 @@ public class TransportTaskService {
@Transactional(readOnly=true) @Transactional(readOnly=true)
public TransportTaskModels.Detail detail(String number) { public TransportTaskModels.Detail detail(String number) {
var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number); var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number);
var commandEvidence=links.evidence(number);
var departureIds=commandEvidence.isEmpty()?List.<Long>of():evidenceJson(commandEvidence.get(0));
var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList()
: "COMPLETED".equals(trip.getStatus()) ? departureIds : List.<Long>of();
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(), return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(), "PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList(),row.getRecordVersion(), "LOCAL_TRUCK".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) ? metadata(trip.getId(),departureIds) :
"PICKUP".equals(row.getTaskType())?reviewPhotos.resources(row):null); "LOCAL_TRUCK".equals(row.getTaskType()) && "COMPLETED".equals(trip.getStatus()) && commandEvidence.size()>1 ? metadata(trip.getId(),departureIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),
"LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),row.getRecordVersion(),
reviewPhotos.resources(row));
} }
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */ /** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);} public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
/** 账本引用去重,不把上传但未确认的文件当作运输完成证据。 */ /** 账本引用去重,不把上传但未确认的文件当作运输完成证据。 */
private List<Long> evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->{try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference<List<Long>>(){}).stream();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}}).distinct().toList();} private List<Long> evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->evidenceJson(value).stream()).distinct().toList();}
/** 读取一条执行命令保存的照片 ID 列表。 */
private List<Long> evidenceJson(String value){try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference<List<Long>>(){}).stream().filter(Objects::nonNull).distinct().toList();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}}
/** 按运输资源核对并加载照片元数据。 */
private List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> metadata(long tripId,List<Long> ids){return ids.stream().map(id->files.metadata(tripId,id)).toList();}
/** 所有命令先锁资源父记录,再由既有服务核对公共执行版本。 */ /** 所有命令先锁资源父记录,再由既有服务核对公共执行版本。 */
public FleetTripEntity transport(String number,boolean lock) { public FleetTripEntity transport(String number,boolean lock) {
var root=roots.find(number);if(root==null)throw missing("排车单不存在"); var root=roots.find(number);if(root==null)throw missing("排车单不存在");
@@ -115,11 +127,11 @@ public class TransportTaskService {
public TransportTaskModels.Detail assign(String number,String operator,TransportTaskModels.Assign request) { public TransportTaskModels.Detail assign(String number,String operator,TransportTaskModels.Assign request) {
var trip=transport(number,true); var trip=transport(number,true);
com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence photos=null; com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence photos=null;
if("PICKUP".equals(trip.getTripPurpose())) { if(Set.of("PICKUP", "LOCAL_TRUCK").contains(trip.getTripPurpose())) {
var row=roots.lock(number); var row=roots.lock(number);
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试"); || !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片"); if(!"PLANNED".equals(trip.getStatus()) || "PICKUP".equals(trip.getTripPurpose()) && pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已开始执行的任务不能更换资源照片");
// 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。 // 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。
var supplied=request.reviewPhotos(); var supplied=request.reviewPhotos();
var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE, var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE,
@@ -127,7 +139,7 @@ public class TransportTaskService {
var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER, var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,
request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds()); request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds());
var license=request.masterLicenseFileId(); var license=request.masterLicenseFileId();
if(license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) { if("PICKUP".equals(trip.getTripPurpose()) && license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) {
var driver=drivers.detail(request.driverId(),null); var driver=drivers.detail(request.driverId(),null);
if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId()); if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId());
} }
@@ -150,10 +162,29 @@ public class TransportTaskService {
reviewPhotos.verifyReview(roots.find(number),trip); reviewPhotos.verifyReview(roots.find(number),trip);
if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息"); if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息");
} }
/** 本土卡车审核校验真实车次、资源、审核照片和原单货量,不依赖仓库派送绑定。 */
public void verifyLocalTruckReview(String number) {
var trip=transport(number,true);
if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !"PLANNED".equals(trip.getStatus()))throw conflict("仅未发车的本土卡车排车可以审核");
var row=roots.find(number);
if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null
|| !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间");
var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm();
requireResources(trip);
reviewPhotos.verifyReview(row,trip);
if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息");
capacity(trip,truck(number));
}
/** 取消运输并保留原单、排车及资源审计。 */ /** 取消运输并保留原单、排车及资源审计。 */
@Transactional @Transactional
public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) { public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) {
var trip=transport(number,true);trips.cancel(trip.getTripNo(),operator,request);return detail(number); var trip=transport(number,true);
// 本土卡车理货通过即进入装车,后续阶段禁止撤销运输安排。
if("LOCAL_TRUCK".equals(trip.getTripPurpose())) {
var row=roots.lock(number);
if(row==null || !"BEFORE_LOADING".equals(row.getStage()))throw conflict("装车中及后续阶段不能取消排车");
}
trips.cancel(trip.getTripNo(),operator,request);return detail(number);
} }
/** 本土卡车发车和完成不制造仓库收发货记录。 */ /** 本土卡车发车和完成不制造仓库收发货记录。 */
@Transactional @Transactional
@@ -165,6 +196,12 @@ public class TransportTaskService {
var order=truck(number); var order=truck(number);
if("depart".equals(action)) { if("depart".equals(action)) {
if(!"PLANNED".equals(trip.getStatus()) || !"DISPATCHED".equals(order.getStatus()))throw conflict("仅已派车的计划任务可发车"); if(!"PLANNED".equals(trip.getStatus()) || !"DISPATCHED".equals(order.getStatus()))throw conflict("仅已派车的计划任务可发车");
// 本土卡车沿用排车主单的车辆维护、调度审核和理货审核三道门禁。
var dispatch = roots.lock(number);
if(dispatch == null || !"LOCAL_TRUCK".equals(dispatch.getTaskType()) || !"LOADING".equals(dispatch.getStage())
|| !"APPROVED".equals(dispatch.getReviewState())) throw conflict("请先完成车辆维护、调度审核和理货员审核");
if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty())throw conflict("请上传至少一张 B 点取货照片");
for(long id:request.evidenceFileIds())files.metadata(trip.getId(),id);
requireResources(trip);capacity(trip,order);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now()); requireResources(trip);capacity(trip,order);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
} else if("complete".equals(action)) { } else if("complete".equals(action)) {
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"IN_TRANSIT".equals(order.getStatus()))throw conflict("请先确认发车"); if(!"IN_TRANSIT".equals(trip.getStatus()) || !"IN_TRANSIT".equals(order.getStatus()))throw conflict("请先确认发车");
@@ -39,12 +39,16 @@ public interface OverseasFeeBusinessMapper {
+ " UNION ALL SELECT customer_id,NULL FROM ll_warehouse_transfer_order UNION ALL SELECT customer_id,NULL FROM ll_return_order" + " UNION ALL SELECT customer_id,NULL FROM ll_warehouse_transfer_order UNION ALL SELECT customer_id,NULL FROM ll_return_order"
+ " UNION ALL SELECT customer_id,NULL FROM ll_us_truck_order UNION ALL SELECT customer_id,NULL FROM ll_local_looseboard_order" + " UNION ALL SELECT customer_id,NULL FROM ll_us_truck_order UNION ALL SELECT customer_id,NULL FROM ll_local_looseboard_order"
+ " UNION ALL SELECT customer_id,NULL FROM ll_relabel_application UNION ALL SELECT customer_id,NULL FROM ll_cargo_discard_application" + " UNION ALL SELECT customer_id,NULL FROM ll_relabel_application UNION ALL SELECT customer_id,NULL FROM ll_cargo_discard_application"
+ " UNION ALL SELECT customer_id,customer_name FROM ll_overseas_procurement_order"
+ " UNION ALL SELECT customer_id,NULL FROM ll_inbound_plan UNION ALL SELECT customer_id,NULL FROM ll_delivery_order) c" + " UNION ALL SELECT customer_id,NULL FROM ll_inbound_plan UNION ALL SELECT customer_id,NULL FROM ll_delivery_order) c"
+ " WHERE customer_id IS NOT NULL AND TRIM(customer_id)!='' GROUP BY customer_id ORDER BY customer_id LIMIT 1000") + " WHERE customer_id IS NOT NULL AND TRIM(customer_id)!='' GROUP BY customer_id ORDER BY customer_id LIMIT 1000")
List<ContainerCustomerOption> customers(); List<ContainerCustomerOption> customers();
/** 头程附加费只认本次服务订单的类型,不能沿货物最初来源推断。 */ /** 头程附加费只认本次服务订单的类型,不能沿货物最初来源推断。 */
@Select("SELECT order_type FROM ll_order WHERE id=#{id} AND customer_id=#{customerId}") @Select("SELECT order_type FROM ll_order WHERE id=#{id} AND customer_id=#{customerId}")
String orderType(@Param("id") long id, @Param("customerId") String customerId); String orderType(@Param("id") long id, @Param("customerId") String customerId);
/** 读取头程订单目的地类型;自有海外仓订单默认走客户余额/授信。 */
@Select("SELECT destination_type FROM ll_order WHERE id=#{id} AND customer_id=#{customerId}")
String destinationType(@Param("id") long id, @Param("customerId") String customerId);
/** 换标履约订单登记费用时,定位同客户的真实换标申请。 */ /** 换标履约订单登记费用时,定位同客户的真实换标申请。 */
@Select("SELECT a.id business_id,'RELABEL_ORDER' business_type,a.relabel_no business_no,a.customer_id," @Select("SELECT a.id business_id,'RELABEL_ORDER' business_type,a.relabel_no business_no,a.customer_id,"
+ "o.customer_name,o.overseas_warehouse_id warehouse_id,o.id order_id,o.order_type" + "o.customer_name,o.overseas_warehouse_id warehouse_id,o.id order_id,o.order_type"
@@ -68,7 +72,6 @@ public interface OverseasFeeBusinessMapper {
return switch (type) { return switch (type) {
case "OVERSEAS_INBOUND" -> new String[]{"ll_inbound_plan", "plan_no", "NULL", "NULL"}; case "OVERSEAS_INBOUND" -> new String[]{"ll_inbound_plan", "plan_no", "NULL", "NULL"};
case "OVERSEAS_PROCUREMENT" -> new String[]{"ll_overseas_procurement_order", "order_no", "NULL", "NULL"}; case "OVERSEAS_PROCUREMENT" -> new String[]{"ll_overseas_procurement_order", "order_no", "NULL", "NULL"};
case "PROCUREMENT_FEE" -> new String[]{"ll_overseas_procurement_fee", "fee_no", "NULL", "NULL"};
case "US_TRUCK" -> new String[]{"ll_us_truck_order", "order_no", "NULL", "NULL"}; case "US_TRUCK" -> new String[]{"ll_us_truck_order", "order_no", "NULL", "NULL"};
case "LOCAL_LOOSEBOARD" -> new String[]{"ll_local_looseboard_order", "order_no", "NULL", "NULL"}; case "LOCAL_LOOSEBOARD" -> new String[]{"ll_local_looseboard_order", "order_no", "NULL", "NULL"};
case "LASTLEG_ORDER" -> new String[]{"ll_order", "order_no", "t.overseas_warehouse_id", "t.order_type"}; case "LASTLEG_ORDER" -> new String[]{"ll_order", "order_no", "t.overseas_warehouse_id", "t.order_type"};
@@ -29,6 +29,8 @@ public class OverseasFeeBusinessRepository {
public List<ContainerCustomerOption> customers() { return mapper.customers(); } public List<ContainerCustomerOption> customers() { return mapper.customers(); }
/** 读取本次业务订单类型。 */ /** 读取本次业务订单类型。 */
public String orderType(long id, String customer) { return mapper.orderType(id, customer); } public String orderType(long id, String customer) { return mapper.orderType(id, customer); }
/** 读取头程订单目的地类型,参与默认结算方式判定。 */
public String destinationType(long id, String customer) { return mapper.destinationType(id, customer); }
/** 从履约主键解析实际换标单,保留客户隔离。 */ /** 从履约主键解析实际换标单,保留客户隔离。 */
public BusinessOption relabelForOrder(long id, String customer) { return mapper.relabelForOrder(id, customer); } public BusinessOption relabelForOrder(long id, String customer) { return mapper.relabelForOrder(id, customer); }
/** 空列表不查询,费用展示采用批量业务事实。 */ /** 空列表不查询,费用展示采用批量业务事实。 */
@@ -79,11 +79,14 @@ public class OverseasFeeSourceService {
if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送"); if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送");
} }
/** 旧采购费用子单已有独立资金身份,不能从通用入口再次收费;新采购主单复用人工收费。 */
private static void requireNonProcurement(String sourceType,String businessType) { private static void requireNonProcurement(String sourceType,String businessType) {
if("OVERSEAS_PROCUREMENT_FEE".equals(sourceType) || "OVERSEAS_PROCUREMENT".equals(businessType) || "PROCUREMENT_FEE".equals(businessType)) if("PROCUREMENT_FEE".equals(businessType)
|| "OVERSEAS_PROCUREMENT_FEE".equals(sourceType) && !"OVERSEAS_PROCUREMENT".equals(businessType))
throw conflict("采购费用必须通过采购履约或财务节点确认,实扣成功后自动关联来源"); throw conflict("采购费用必须通过采购履约或财务节点确认,实扣成功后自动关联来源");
} }
/** 创建真实业务关联费用并保留同键同载荷重放。 */
@Transactional @Transactional
public OverseasFeeSource create(String operatorId, String key, SaveRequest request) { public OverseasFeeSource create(String operatorId, String key, SaveRequest request) {
requireNonProcurement(request.sourceType(),request.businessType()); requireNonProcurement(request.sourceType(),request.businessType());
@@ -291,7 +294,10 @@ public class OverseasFeeSourceService {
value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim()); value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim());
value.setFeeRemark(text(request.feeRemark())); value.setFeeRemark(text(request.feeRemark()));
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName())); value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); value.setSupplierCategory(text(request.supplierCategory()));
// 新采购费用默认从余额结算,显式选择账单时仍遵守统一海外费用规则。
value.setSettlementMode("OVERSEAS_PROCUREMENT".equals(request.businessType()) && text(request.settlementMode()) == null
? "WALLET" : text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo())); value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
OverseasFeeCurrencyConverter.Conversion conversion = confirmedConversion != null ? confirmedConversion OverseasFeeCurrencyConverter.Conversion conversion = confirmedConversion != null ? confirmedConversion
@@ -109,7 +109,7 @@ public class OverseasManualFeeService implements WalletResultHandler {
/** 候选使用真实服务业务单和当前客户名称,不允许以车辆车次合并客户费用。 */ /** 候选使用真实服务业务单和当前客户名称,不允许以车辆车次合并客户费用。 */
public List<BusinessOption> businessOptions(String type, String customerId, String keyword) { public List<BusinessOption> businessOptions(String type, String customerId, String keyword) {
if (!Set.of("LASTLEG_ORDER", "RELABEL_ORDER", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "US_TRUCK", "LOCAL_LOOSEBOARD", "DISCARD_ORDER", "OVERSEAS_INBOUND", "DELIVERY_ORDER").contains(type)) if (!Set.of("LASTLEG_ORDER", "RELABEL_ORDER", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "US_TRUCK", "LOCAL_LOOSEBOARD", "DISCARD_ORDER", "OVERSEAS_INBOUND", "DELIVERY_ORDER", "OVERSEAS_PROCUREMENT").contains(type))
throw bad("请选择实际服务业务单"); throw bad("请选择实际服务业务单");
var options = businesses.options(type, text(customerId), text(keyword)); var options = businesses.options(type, text(customerId), text(keyword));
var names = customerNames.resolve(options.stream() var names = customerNames.resolve(options.stream()
@@ -452,20 +452,28 @@ public class OverseasManualFeeService implements WalletResultHandler {
private boolean bill(OverseasFeeSource fee) { private boolean bill(OverseasFeeSource fee) {
if ("RECEIVABLE".equals(fee.getDirection()) && "WALLET".equals(fee.getSettlementMode())) return false; if ("RECEIVABLE".equals(fee.getDirection()) && "WALLET".equals(fee.getSettlementMode())) return false;
if ("RECEIVABLE".equals(fee.getDirection()) && "BILL".equals(fee.getSettlementMode())) return true; if ("RECEIVABLE".equals(fee.getDirection()) && "BILL".equals(fee.getSettlementMode())) return true;
return !"RECEIVABLE".equals(fee.getDirection()) || "LASTLEG_ORDER".equals(fee.getBusinessType()) if (!"RECEIVABLE".equals(fee.getDirection())) return true;
&& "FIRST_LEG".equals(businesses.orderType(fee.getBusinessId(), fee.getCustomerId())); if (!"LASTLEG_ORDER".equals(fee.getBusinessType())) return false;
if (!"FIRST_LEG".equals(businesses.orderType(fee.getBusinessId(), fee.getCustomerId()))) return false;
// 头程货物进入自有海外仓后,后续人工费用默认从客户余额/授信扣除;显式 BILL 仍优先。
return !"OVERSEAS".equals(businesses.destinationType(fee.getBusinessId(), fee.getCustomerId()));
} }
/** 采购已有专用锁款与履约结算,不允许通用费用入口重复收取。 */ /** 自动仓租与旧采购子费用已有独立资金身份,新采购主单允许使用人工费用链。 */
private void requireManual(OverseasFeeSource fee) { private void requireManual(OverseasFeeSource fee) {
if(StorageRentFeeWorkflow.isRent(fee))throw conflict("自动仓租请通过仓租审核或调整入口处理"); if(StorageRentFeeWorkflow.isRent(fee))throw conflict("自动仓租请通过仓租审核或调整入口处理");
if (procurement(fee)) throw conflict("采购费用请通过采购履约或财务节点处理"); if (procurement(fee)) throw conflict("采购费用请通过采购履约或财务节点处理");
} }
/** 识别采购来源及其工单类型,兼容历史费用数据。 */ /** 仅旧采购子费用禁止重复进入人工链,新采购主单费用正常冻结和审核。 */
private boolean procurement(OverseasFeeSource fee) { private boolean procurement(OverseasFeeSource fee) {
return "OVERSEAS_PROCUREMENT_FEE".equals(fee.getSourceType()) || "OVERSEAS_PROCUREMENT".equals(fee.getBusinessType()) return "PROCUREMENT_FEE".equals(fee.getBusinessType())
|| "PROCUREMENT_FEE".equals(fee.getBusinessType()); || "OVERSEAS_PROCUREMENT_FEE".equals(fee.getSourceType()) && !"OVERSEAS_PROCUREMENT".equals(fee.getBusinessType());
}
/** 客户采购详情按精确主单与客户读取费用,资金状态取回执后的统一投影。 */
public List<OverseasFeeSource> procurementFees(String customerId, long orderId) {
return describe(sources.listForBusiness(customerId, "OVERSEAS_PROCUREMENT", orderId));
} }
/** 仓租只由实际出库自动生成,人工收费和调价不得重复录入。 */ /** 仓租只由实际出库自动生成,人工收费和调价不得重复录入。 */
@@ -28,15 +28,15 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token; client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token;
} }
/** MIME和字节签名一起校验,File仍执行其权威校验。 */ /** MIME和字节签名一起校验,File仍执行其权威校验。 */
public static void validate(Upload upload) { public static void validate(PhotoSlot slot,Upload upload) {
if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255 if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255
|| upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE || upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE
|| !image(upload.contentType()) || !signature(upload.contentType(),upload.content())) || !accepted(slot,upload.contentType()) || !signature(upload.contentType(),upload.content()))
throw bad("照片须为非空真实JPG/PNG,最大10MiB"); throw bad("资料须为真实JPG/PNG;司机驾驶证也支持PDF,最大10MiB");
} }
/** 使用原图片字节,未知结果不能换键或自动重传。 */ /** 使用原图片字节,未知结果不能换键或自动重传。 */
@Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) { @Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) {
validate(upload);var body=new MultipartBodyBuilder(); validate(slot,upload);var body=new MultipartBodyBuilder();
body.part("file",new ByteArrayResource(upload.content()){ body.part("file",new ByteArrayResource(upload.content()){
/** 保留本轮原文件名,不生成占位证据。 */ /** 保留本轮原文件名,不生成占位证据。 */
@Override public String getFilename(){return upload.fileName();} @Override public String getFilename(){return upload.fileName();}
@@ -64,15 +64,16 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
var m=response.data(); var m=response.data();
if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType()) if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType())
|| !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId()) || !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId())
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType()) || m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !accepted(slot,m.contentType())
|| m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效"); || m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效");
return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString()); return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString());
} }
/** 不放开PDF、SVG或任意image类型。 */ /** 仅驾驶证类别接受PDF,其他装车及油耗类别仍只接受JPG/PNG。 */
private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);} private static boolean accepted(PhotoSlot slot,String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type) || slot==PhotoSlot.LOADING_SCENE && "application/pdf".equals(type);}
/** 使用真实文件头,不转换或伪造证据图片。 */ /** 使用真实文件头,不转换或伪造证据图片。 */
private static boolean signature(String type,byte[] bytes) { private static boolean signature(String type,byte[] bytes) {
if(bytes==null)return false; if(bytes==null)return false;
if("application/pdf".equals(type))return bytes.length>=5 && bytes[0]=='%' && bytes[1]=='P' && bytes[2]=='D' && bytes[3]=='F' && bytes[4]=='-';
if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255; if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255;
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false; byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true; for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
@@ -26,22 +26,27 @@ public interface OwnedBusinessQueryMapper {
String SOURCE=""" String SOURCE="""
SELECT SELECT
"""+POOLED_CARGO+" AS pooled_cargo,"+""" """+POOLED_CARGO+" AS pooled_cargo,"+"""
(EXISTS (SELECT 1 FROM ll_fleet_pickup_task picked WHERE CAST(picked.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND picked.status IN ('PICKED','ARRIVED')) AND NOT EXISTS (SELECT 1 FROM ll_fleet_pickup_task p WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status='ASSIGNED')) AS transport_ready,
d.task_type,t.stops_json AS transport_stops_json,t.status AS transport_trip_status,
d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id, d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at, d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json, d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode, o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version, o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version,
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id LEFT JOIN ll_fleet_trip t ON t.id=d.trip_id
"""; """;
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */ /** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
String FILTER=""" String FILTER="""
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED') d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL AND ((d.task_type IN ('LOCAL_TRUCK','PICKUP') AND d.departed_at IS NOT NULL AND t.status IN ('IN_TRANSIT','COMPLETED')
AND d.stage IN ('DELIVERING','DELIVERED')
AND (d.task_type='LOCAL_TRUCK' OR EXISTS (SELECT 1 FROM ll_fleet_pickup_task picked WHERE CAST(picked.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND picked.status IN ('PICKED','ARRIVED')) AND NOT EXISTS (SELECT 1 FROM ll_fleet_pickup_task p WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status='ASSIGNED')))
OR (d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
AND o.vehicle_ownership IN ('OWNED','DISPATCHED') AND o.vehicle_ownership IN ('OWNED','DISPATCHED')
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128)) AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
AND o.loading_completed_at=d.loading_completed_at AND o.loading_completed_at=d.loading_completed_at
AND (o.resource_mode='MASTER' OR (o.vehicle_ownership='DISPATCHED' AND o.resource_mode='EXTERNAL')) AND (o.resource_mode='MASTER' OR (o.vehicle_ownership='DISPATCHED' AND o.resource_mode='EXTERNAL'))))
AND <if test='!q.scope.pooledCargo'>NOT</if> AND <if test='!q.scope.pooledCargo'>NOT</if>
"""+POOLED_CARGO+""" """+POOLED_CARGO+"""
<if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if> <if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if>
@@ -49,7 +54,7 @@ public interface OwnedBusinessQueryMapper {
<if test='q.stage != null'> AND d.stage=#{q.stage}</if> <if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if> <if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.keyword != null'> AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%') <if test='q.keyword != null'> AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%')
OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR (d.task_type IN ('LOCAL_TRUCK','PICKUP') AND t.stops_json LIKE CONCAT('%',#{q.keyword},'%')) OR EXISTS(SELECT 1 FROM ll_dispatch_item i
WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%') WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%') OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%'))))</if> OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%'))))</if>
@@ -58,7 +63,7 @@ public interface OwnedBusinessQueryMapper {
@Select("<script>"+SOURCE+" WHERE "+FILTER+" ORDER BY d.planned_departure_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>") @Select("<script>"+SOURCE+" WHERE "+FILTER+" ORDER BY d.planned_departure_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<Source> page(@Param("q") Search query); List<Source> page(@Param("q") Search query);
/** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */ /** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */
@Select("<script>SELECT d.stage,COUNT(*) AS quantity FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id WHERE "+FILTER+" GROUP BY d.stage</script>") @Select("<script>SELECT d.stage,COUNT(*) AS quantity FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id LEFT JOIN ll_fleet_trip t ON t.id=d.trip_id WHERE "+FILTER+" GROUP BY d.stage</script>")
List<StageCount> summary(@Param("q") Search query); List<StageCount> summary(@Param("q") Search query);
/** 先读原主单和可空归属,以明确区别无快照与不存在。 */ /** 先读原主单和可空归属,以明确区别无快照与不存在。 */
@Select(SOURCE+" WHERE d.dispatch_no=#{number}") @Select(SOURCE+" WHERE d.dispatch_no=#{number}")
@@ -53,7 +53,7 @@ public final class OwnedBusinessModels {
public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt, public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt,
String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver, String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver,
List<Destination> destinations,Integer actualPalletCount,Operations operations,String unitPrice, List<Destination> destinations,Integer actualPalletCount,Operations operations,String unitPrice,
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues) {} String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues,String taskType) {}
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {} public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {} public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo, public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
@@ -14,7 +14,8 @@ public final class OwnedBusinessRows {
private LocalDateTime ownershipCapturedAt,loadingCompletedAt; private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
} }
@Data public static class Source { @Data public static class Source {
private Boolean pooledCargo; private Boolean pooledCargo,transportReady;
private String taskType,transportStopsJson,transportTripStatus;
private Long id,recordVersion; private Long id,recordVersion;
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName; private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson; private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
@@ -9,23 +9,27 @@ public interface DispatchLoadingPhotoPort {
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT } enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {} record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId, record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) { @Pattern(regexp="[1-9][0-9]*") String replaceFileId,@NotBlank @Size(min=8,max=128) String uploadKey) {
/** 未指定替换目标时追加照片,每类最多五张。 */ /** 未指定替换目标时追加照片,每类最多五张。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); } public BindPhoto(String recordVersion,String idempotencyKey,String fileId,String uploadKey) { this(recordVersion,idempotencyKey,fileId,null,uploadKey); }
} }
record State(String recordVersion,List<Photo> photos) {} record State(String recordVersion,List<Photo> photos) {}
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {} record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String reviewRound,String stage,boolean editable,boolean complete,List<Photo> photos) {}
record Download(Photo photo,byte[] content) {} record Download(Photo photo,byte[] content) {}
/** 只读取原整车附加资料版本与五框,不初始化记录。 */ /** 只读取原整车附加资料版本与五框,不初始化记录。 */
State read(long dispatchId); State read(long dispatchId);
/** 已持排车父锁;仅为空类别复制本趟已核验资料,与理货审核同事务保存。 */
void importIfEmpty(long dispatchId,String operator,Slot slot,List<MultipartFile> files);
/** 已持排车父锁;资源变化使对应当前资料失效,旧文件与版本历史保留。 */
void invalidateResourcePhotos(long dispatchId,String operator,java.util.Set<Slot> slots);
/** 上传保存原请求意图;不提前计入已保存照片。 */ /** 上传保存原请求意图;不提前计入已保存照片。 */
Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file); Photo upload(long dispatchId,String operator,Slot slot,long reviewRound,String key,MultipartFile file);
/** 仅原操作者可恢复原上传结果。 */ /** 仅原操作者可恢复原上传结果。 */
Photo recover(long dispatchId,String operator,String key); Photo recover(long dispatchId,String operator,String key);
/** 先校验原槽位、完整载荷与操作者;无原成功命令时返回null。 */ /** 先校验原槽位、完整载荷与操作者;无原成功命令时返回null。 */
Documents replayBind(long dispatchId,String operator,Slot slot,BindPhoto request); Documents replayBind(long dispatchId,String operator,Slot slot,BindPhoto request);
/** 调用者已持排车父锁;保存照片、资料版本及历史但不单独提交事务。 */ /** 调用者已持排车父锁;保存照片、资料版本及历史但不单独提交事务。 */
State bind(long dispatchId,String operator,Slot slot,BindPhoto request); State bind(long dispatchId,String operator,Slot slot,long reviewRound,BindPhoto request);
/** 与绑定及自动发车共同提交完整原结果。 */ /** 与绑定及自动发车共同提交完整原结果。 */
void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result); void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result);
/** 文件只可从本车当前已绑定槽位下载。 */ /** 文件只可从本车当前已绑定槽位下载。 */
@@ -31,6 +31,37 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files; this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
} }
public record Binding(Slot slot,BindPhoto request) {} public record Binding(Slot slot,BindPhoto request) {}
/** 与资源修改同事务失效旧凭证;不变类别及历史保留,旧资料版本不得继续保存。 */
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public void invalidateResourcePhotos(long id,String operator,Set<Slot> slots) {
var row=repository.record(id);
if(row==null || slots.isEmpty())return;
row=repository.lock(row);
var current=projection.photos(row);
var kept=current.stream().filter(photo->slots.stream().noneMatch(slot->photo.slot()==slot(slot))).toList();
if(kept.size()==current.size())return;
long version=row.getRecordVersion();
row.setPhotosJson(codec.write(kept));row.setRecordVersion(Math.addExact(version,1));
row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
history.setAction("INVALIDATE_RESOURCE_PHOTOS");history.setOperatorId(operator);history.setReason("车辆或司机资料变化,原凭证失效");history.setOccurredAt(row.getUpdatedAt());
history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),kept,operator)));repository.history(history);
}
/** 自动带入只填空类;重复审核及已有现场照片不重复追加或覆盖。 */
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public void importIfEmpty(long id,String operator,Slot slot,List<MultipartFile> sources) {
var state=read(id);
if(state.photos().stream().anyMatch(photo->photo.slot()==slot) || sources.isEmpty())return;
if(sources.size()>5)throw bad("每类装车资料最多5份");
for(var source:sources) {
try {
var payload=new OwnedBusinessFileGateway.Upload(source.getOriginalFilename(),source.getContentType(),source.getBytes());
var uploaded=files.upload(id,slot(slot),operator,payload);
state=savePhoto(id,operator,slot,codec.version(state.recordVersion()),uploaded.fileId(),null);
} catch(IOException failure) { throw bad("读取本趟资料失败,未完成带入"); }
}
}
/** GET只读;首次保存前资料版本为0。 */ /** GET只读;首次保存前资料版本为0。 */
@Override public State read(long id) { @Override public State read(long id) {
var row=repository.record(id); var row=repository.record(id);
@@ -38,13 +69,14 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
.filter(p->"LOADING".equals(p.category())).map(this::publicPhoto).toList()); .filter(p->"LOADING".equals(p.category())).map(this::publicPhoto).toList());
} }
/** 先落稳定上传意图,再请求File;未知结果不允许换键重传。 */ /** 先落稳定上传意图,再请求File;未知结果不允许换键重传。 */
@Override public Photo upload(long id,String operator,Slot slot,String key,MultipartFile multipart) { @Override public Photo upload(long id,String operator,Slot slot,long reviewRound,String key,MultipartFile multipart) {
codec.key(key);var originalSlot=slot(slot); codec.key(key);var originalSlot=slot(slot);
if(reviewRound<=0)throw bad("审核轮次无效");
if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload); HttpOwnedBusinessFileGateway.validate(originalSlot,payload);
var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); var identity=new LoadingUploadIdentity(reviewRound,new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)));
var reservation=commands.reserveUpload(id,operator(operator),key,identity); var reservation=commands.reserveUpload(id,operator(operator),key,identity);
if(!reservation.created())return uploaded(reservation.command()); if(!reservation.created())return uploaded(reservation.command());
try { try {
@@ -70,15 +102,27 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
} }
/** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */ /** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public State bind(long id,String operator,Slot slot,BindPhoto request) { public State bind(long id,String operator,Slot slot,long reviewRound,BindPhoto request) {
validate(slot,request);long version=codec.version(request.recordVersion());var originalSlot=slot(slot); validate(slot,request);
var verified=photo(originalSlot,files.metadata(id,originalSlot,codec.version(request.fileId()))); var command=commands.existing(id,request.uploadKey(),operator(operator));
if(command==null)throw conflict("未找到本次照片的原上传记录,请重新上传");
var uploaded=uploaded(command);
var identity=codec.read(command.getRequestJson(),LoadingUploadIdentity.class);
if(identity.reviewRound()!=reviewRound)throw conflict("照片所属审核轮次已变化,请刷新后重新上传");
if(uploaded.slot()!=slot || !Objects.equals(uploaded.fileId(),request.fileId()))throw bad("照片与原上传记录不一致");
return savePhoto(id,operator,slot,codec.version(request.recordVersion()),codec.version(request.fileId()),request.replaceFileId());
}
public record LoadingUploadIdentity(long reviewRound,OwnedBusinessPhotoService.UploadIdentity file) {}
/** 自动复制与已核验上传共用保存逻辑;外部绑定必须先通过上传轮次校验。 */
private State savePhoto(long id,String operator,Slot slot,long version,long fileId,String replaceFileId) {
var originalSlot=slot(slot);
var verified=photo(originalSlot,files.metadata(id,originalSlot,fileId));
var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(id); var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(id);
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed); var row=repository.lock(seed);
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()); var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId()); var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,replaceFileId);
row.setPhotosJson(codec.write(photos));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); row.setPhotosJson(codec.write(photos));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion()); var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
@@ -103,6 +147,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
/** 版本0合法,文件编号必须为正且请求键不得缺失。 */ /** 版本0合法,文件编号必须为正且请求键不得缺失。 */
private void validate(Slot slot,BindPhoto request) { private void validate(Slot slot,BindPhoto request) {
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion()); slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
codec.key(request.uploadKey());
if(codec.version(request.fileId())<=0)throw bad("文件编号无效"); if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效"); if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效");
} }
@@ -43,7 +43,7 @@ public class OwnedBusinessPhotoService {
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");} byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload); HttpOwnedBusinessFileGateway.validate(slot,payload);
var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(source.getId(),actor,key,identity); var reservation=commands.reserveUpload(source.getId(),actor,key,identity);
if(!reservation.created()) { if(!reservation.created()) {
@@ -34,7 +34,8 @@ public class OwnedBusinessProjection {
if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key; if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key;
} }
/** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */ /** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */
public int palletCount(Source source) { public Integer palletCount(Source source) {
if(isTransport(source))return null; // 场外运输没有仓库实托事实,保持未知。
var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class); var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class);
if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失"); if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失");
var ids=new HashSet<Long>(); var ids=new HashSet<Long>();
@@ -52,7 +53,7 @@ public class OwnedBusinessProjection {
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) { public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) {
var resources=codec.read(source.getResourcesJson(),Resources.class); var resources=codec.read(source.getResourcesJson(),Resources.class);
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失"); if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source); var ops=operations(record);var photos=photos(record);Integer pallets=palletCount(source);
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片"); if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt()); var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
var destinations=new LinkedHashMap<String,Destination>(); var destinations=new LinkedHashMap<String,Destination>();
@@ -62,17 +63,33 @@ public class OwnedBusinessProjection {
item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment( item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment(
b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k))); b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k)));
} }
String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString(); if(isTransport(source)) {
var stops=codec.list(source.getTransportStopsJson(),com.baiyue.lastleg.fleet.trip.FleetTripModels.Stop.class);
for(int index=1;index<stops.size();index++) {
var stop=stops.get(index);String key=stop.destinationKey()==null?"TRANSPORT_STOP:"+index:stop.destinationKey();
destinations.put(key,new Destination(key,null,stop.name(),stop.address(),List.of(),arrivals.get(key)));
}
}
String price=ops.money()==null || pallets==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString();
var driver=resources.driver(); var driver=resources.driver();
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()), return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()), codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()), origin(source),
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),source.getOwnedVehicleOwnership()), new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),source.getOwnedVehicleOwnership()),
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price, new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()), record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()), new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of()); new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==null?List.of("运输任务无仓库实托记录,单价未计算"):pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of(),Objects.requireNonNullElse(source.getTaskType(),"DELIVERY"));
} }
/** 运输起点取真实行程首站,仓库任务仍取原起运仓。 */
private Origin origin(Source source) {
if(isTransport(source)) {
var stops=codec.list(source.getTransportStopsJson(),com.baiyue.lastleg.fleet.trip.FleetTripModels.Stop.class);
if(!stops.isEmpty())return new Origin(null,null,stops.get(0).name());
}
return new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName());
}
private boolean isTransport(Source source){return Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(source.getTaskType(),""));}
/** 承运商仅取原资源快照,列表不逐车查询当前档案。 */ /** 承运商仅取原资源快照,列表不逐车查询当前档案。 */
public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();} public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();}
/** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */ /** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */
@@ -90,6 +90,16 @@ public class OwnedBusinessQueryService {
} }
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */ /** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
private void eligible(Source source,OwnedBusinessScope scope) { private void eligible(Source source,OwnedBusinessScope scope) {
// 运输任务以已取货并发车为运营依据,不冒用仓库实托装车快照。
if(Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(source.getTaskType(),""))) {
if(scope.isPooledCargo())throw scope.missing();
if("PICKUP".equals(source.getTaskType()) && !Boolean.TRUE.equals(source.getTransportReady()))throw conflict("请先完成本车全部取货");
if(!Boolean.FALSE.equals(source.getPooledCargo()) || !"ACTIVE".equals(source.getStatus())
|| !Set.of("DELIVERING","DELIVERED").contains(Objects.toString(source.getStage(),""))
|| source.getDepartedAt()==null || !Set.of("IN_TRANSIT","COMPLETED").contains(Objects.toString(source.getTransportTripStatus(),"")))
throw conflict("运输任务尚未完成取货发车");
return;
}
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务"); if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
if(!Set.of("OWNED","DISPATCHED").contains(source.getOwnedVehicleOwnership()))throw conflict("装车车辆归属不合法"); if(!Set.of("OWNED","DISPATCHED").contains(source.getOwnedVehicleOwnership()))throw conflict("装车车辆归属不合法");
if(source.getPooledCargo()==null)throw conflict("本单缺少实际拼车分类依据"); if(source.getPooledCargo()==null)throw conflict("本单缺少实际拼车分类依据");
@@ -99,6 +99,11 @@ public class OwnedBusinessRecordService {
} }
if(ops.arrivals()==null)throw bad("目的地到达登记不完整"); if(ops.arrivals()==null)throw bad("目的地到达登记不完整");
var destinations=new HashSet<String>();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item)); var destinations=new HashSet<String>();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item));
// 运输任务按真实行程登记到达,不要求不存在的仓库货物行。
if(Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(source.getTaskType(),""))) {
var stops=codec.list(source.getTransportStopsJson(),com.baiyue.lastleg.fleet.trip.FleetTripModels.Stop.class);
for(int i=1;i<stops.size();i++)destinations.add(stops.get(i).destinationKey()==null?"TRANSPORT_STOP:"+i:stops.get(i).destinationKey());
}
var seen=new HashSet<String>();var arrivals=new ArrayList<Arrival>(); var seen=new HashSet<String>();var arrivals=new ArrayList<Arrival>();
for(var arrival:ops.arrivals()) { for(var arrival:ops.arrivals()) {
if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车"); if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车");
@@ -12,7 +12,7 @@ final class TripOperationsCharts {
var amounts=new TreeMap<String,Map<String,BigDecimal>>();var rateAmounts=new TreeMap<String,Map<String,BigDecimal>>();var quantities=new TreeMap<String,Map<String,BigDecimal>>(); var amounts=new TreeMap<String,Map<String,BigDecimal>>();var rateAmounts=new TreeMap<String,Map<String,BigDecimal>>();var quantities=new TreeMap<String,Map<String,BigDecimal>>();
for(var row:rows) { for(var row:rows) {
String day=row.departedAt().atOffset(ZoneOffset.UTC).toLocalDate().toString(); String day=row.departedAt().atOffset(ZoneOffset.UTC).toLocalDate().toString();
pallets.merge(day,BigDecimal.valueOf(row.physicalPallets()),BigDecimal::add); if(row.physicalPallets()!=null)pallets.merge(day,BigDecimal.valueOf(row.physicalPallets()),BigDecimal::add);
for(var destination:row.destinations()) {destinations.merge(destination.key(),BigDecimal.ONE,BigDecimal::add);labels.put(destination.key(),destination.label());} for(var destination:row.destinations()) {destinations.merge(destination.key(),BigDecimal.ONE,BigDecimal::add);labels.put(destination.key(),destination.label());}
for(var money:row.externalAgreedAmounts())amounts.computeIfAbsent(money.currency(),ignored->new TreeMap<>()).merge(day,money.amount(),BigDecimal::add); for(var money:row.externalAgreedAmounts())amounts.computeIfAbsent(money.currency(),ignored->new TreeMap<>()).merge(day,money.amount(),BigDecimal::add);
for(var rate:row.externalPalletRates()) { for(var rate:row.externalPalletRates()) {
@@ -23,9 +23,18 @@ public final class TripOperationsModels {
public record Charts(List<Series> physicalPallets,List<Series> destinationTrips,List<Series> externalAgreedAmounts,List<Series> externalPalletRates) { } public record Charts(List<Series> physicalPallets,List<Series> destinationTrips,List<Series> externalAgreedAmounts,List<Series> externalPalletRates) { }
public record Row(String tripNo, String status, String vehiclePlate, String driverName, public record Row(String tripNo, String status, String vehiclePlate, String driverName,
String warehouseName, Instant departedAt, Instant completedAt, List<Destination> destinations, String warehouseName, Instant departedAt, Instant completedAt, List<Destination> destinations,
long physicalPallets, long palletSpaces, long ownPackages, long externalPackages, Long physicalPallets, Long palletSpaces, long ownPackages, long externalPackages,
List<Money> externalAgreedAmounts, List<PalletRate> externalPalletRates, TripOperationsRecordModels.Details measured, List<Money> externalAgreedAmounts, List<PalletRate> externalPalletRates, TripOperationsRecordModels.Details measured,
String financeStatus, String costStatus, FleetTripCostModels.Summary costs) { String financeStatus, String costStatus, FleetTripCostModels.Summary costs) {
/** 原仓库调用保留精确整数构造;运输无实托时使用可空事实。 */
public Row(String tripNo,String status,String vehiclePlate,String driverName,String warehouseName,
Instant departedAt,Instant completedAt,List<Destination> destinations,long physicalPallets,long palletSpaces,
long ownPackages,long externalPackages,List<Money> externalAgreedAmounts,List<PalletRate> externalPalletRates,
TripOperationsRecordModels.Details measured,String financeStatus,String costStatus,FleetTripCostModels.Summary costs) {
this(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations,
Long.valueOf(physicalPallets),Long.valueOf(palletSpaces),ownPackages,externalPackages,externalAgreedAmounts,
externalPalletRates,measured,financeStatus,costStatus,costs);
}
Row withCosts(FleetTripCostModels.Summary value, String state) { Row withCosts(FleetTripCostModels.Summary value, String state) {
return new Row(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations,physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts,externalPalletRates,measured,financeStatus,state,value); return new Row(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations,physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts,externalPalletRates,measured,financeStatus,state,value);
} }
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.fleet.reporting;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */
@Mapper
public interface TripOperationsQueryMapper {
@Select("""
<script>
SELECT t.trip_no,t.status,t.completed_at,
(SELECT h.snapshot_json FROM ll_fleet_trip_history h WHERE h.trip_id=t.id
AND (h.action='DEPART' OR
(t.trip_purpose='LOCAL_TRUCK' AND h.action='LOCAL_TRUCK_DEPART') OR
(t.trip_purpose='PICKUP' AND h.action IN ('PICKUP_DEPART','PICKUP_PICKED')
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.departedAt')) IS NOT NULL
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.departedAt'))!='null'))
ORDER BY h.record_version LIMIT 1) AS snapshot,
(SELECT r.details_json FROM ll_fleet_trip_operations_record r
WHERE CAST(r.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512))
ORDER BY r.record_version DESC LIMIT 1) AS measured,
CASE WHEN t.trip_purpose IN ('LOCAL_TRUCK','PICKUP') THEN
JSON_OBJECT('taskType',t.trip_purpose,'packages',
CASE WHEN t.trip_purpose='LOCAL_TRUCK' THEN
(SELECT d.package_count FROM ll_dispatch_order d WHERE d.trip_id=t.id AND d.task_type='LOCAL_TRUCK' LIMIT 1)
ELSE (SELECT SUM(p.actual_packages) FROM ll_fleet_pickup_task p
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
ELSE NULL END AS transport_facts
FROM ll_fleet_trip t
WHERE t.departed_at &gt;= #{q.from} AND t.departed_at &lt; #{q.to}
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
<if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if>
ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001
</script>
""")
List<TripOperationsRepository.Departure> departures(@Param("q") TripOperationsModels.Query query);
}
@@ -1,32 +1,20 @@
package com.baiyue.lastleg.fleet.reporting; package com.baiyue.lastleg.fleet.reporting;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.*; import java.time.Instant;
import java.util.*; import java.util.List;
/** 出车只读事实由 Mapper 读取,运输任务与仓库发车分别取各自历史。 */
@Repository @Repository
public class TripOperationsRepository { public class TripOperationsRepository {
record Departure(String tripNo, String status, Instant completedAt, String snapshot, String measured) { } public record Departure(String tripNo, String status, Instant completedAt, String snapshot, String measured, String transportFacts) {
private final JdbcTemplate jdbc; @org.apache.ibatis.annotations.AutomapConstructor
public TripOperationsRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } public Departure {}
List<Departure> departures(TripOperationsModels.Query query) { public Departure(String tripNo,String status,Instant completedAt,String snapshot,String measured) {
StringBuilder sql = new StringBuilder(""" this(tripNo,status,completedAt,snapshot,measured,null);
SELECT t.trip_no,t.status,t.completed_at, }
(SELECT h.snapshot_json FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART'
ORDER BY h.record_version LIMIT 1) AS snapshot_json,
(SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no
ORDER BY r.record_version DESC LIMIT 1) AS measured_json
FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ?
""");
var args = new ArrayList<Object>();
args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC));
if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); }
if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); }
// Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot.
sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001");
return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"),
r.getTimestamp("completed_at") == null ? null : r.getTimestamp("completed_at").toLocalDateTime().toInstant(ZoneOffset.UTC),
r.getString("snapshot_json"),r.getString("measured_json")), args.toArray());
} }
private final TripOperationsQueryMapper mapper;
public TripOperationsRepository(TripOperationsQueryMapper mapper) { this.mapper=mapper; }
List<Departure> departures(TripOperationsModels.Query query) { return mapper.departures(query); }
} }
@@ -53,6 +53,8 @@ public class TripOperationsService {
if (fact.snapshot()==null) throw new IllegalStateException("实际发车缺少不可变历史,请核对车次"+fact.tripNo()); if (fact.snapshot()==null) throw new IllegalStateException("实际发车缺少不可变历史,请核对车次"+fact.tripNo());
var snapshot=json.readValue(fact.snapshot(),FleetTripModels.AuditSnapshot.class); var snapshot=json.readValue(fact.snapshot(),FleetTripModels.AuditSnapshot.class);
var trip=snapshot.trip(); var load=snapshot.confirmedLoad(); var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),"")))
return transport(fact,trip);
if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty()) if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty())
throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo()); throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>(); var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>();
@@ -104,11 +106,29 @@ public class TripOperationsService {
fact.measured()==null?null:json.readValue(fact.measured(),TripOperationsRecordModels.Details.class),"NOT_QUERIED","NOT_QUERIED",null); fact.measured()==null?null:json.readValue(fact.measured(),TripOperationsRecordModels.Details.class),"NOT_QUERIED","NOT_QUERIED",null);
} catch(JsonProcessingException e) { throw new IllegalStateException("发车历史无法读取,请核对车次"+fact.tripNo(),e); } } catch(JsonProcessingException e) { throw new IllegalStateException("发车历史无法读取,请核对车次"+fact.tripNo(),e); }
} }
/** 运输车次没有仓库实托清单,展示真实行程与取货箱数,实托/托位保持未知。 */
private TripOperationsModels.Row transport(TripOperationsRepository.Departure fact,FleetTripModels.Response trip) throws JsonProcessingException {
if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2)
throw new IllegalStateException("运输发车历史缺少行程或实际时间");
var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts());
if(facts==null || !trip.tripPurpose().equals(facts.path("taskType").asText()))throw new IllegalStateException("运输出车缺少来源依据");
// 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。
var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue();
if(quantity<0)throw new IllegalStateException("运输箱数不合法");
var destinations=new ArrayList<TripOperationsModels.Destination>();
for(int i=1;i<trip.stops().size();i++) {
var stop=trip.stops().get(i);destinations.add(new TripOperationsModels.Destination(
stop.destinationKey()==null?"TRANSPORT_STOP:"+i:stop.destinationKey(),stop.name()));
}
return new TripOperationsModels.Row(fact.tripNo(),fact.status(),trip.vehiclePlate(),trip.driverName(),trip.warehouseName(),
trip.departedAt(),fact.completedAt(),List.copyOf(destinations),(Long)null,(Long)null,quantity,0,List.of(),List.of(),
fact.measured()==null?null:json.readValue(fact.measured(),TripOperationsRecordModels.Details.class),"NOT_QUERIED","NOT_QUERIED",null);
}
static TripOperationsModels.Summary summary(List<TripOperationsModels.Row> rows,boolean costVisible) { static TripOperationsModels.Summary summary(List<TripOperationsModels.Row> rows,boolean costVisible) {
var money=new TreeMap<String,BigDecimal>(); var money=new TreeMap<String,BigDecimal>();
rows.forEach(row -> row.externalAgreedAmounts().forEach(m -> money.merge(m.currency(),m.amount(),BigDecimal::add))); rows.forEach(row -> row.externalAgreedAmounts().forEach(m -> money.merge(m.currency(),m.amount(),BigDecimal::add)));
return new TripOperationsModels.Summary(rows.size(),rows.stream().filter(r -> "IN_TRANSIT".equals(r.status())).count(),rows.stream().filter(r -> "COMPLETED".equals(r.status())).count(), return new TripOperationsModels.Summary(rows.size(),rows.stream().filter(r -> "IN_TRANSIT".equals(r.status())).count(),rows.stream().filter(r -> "COMPLETED".equals(r.status())).count(),
rows.stream().mapToLong(TripOperationsModels.Row::physicalPallets).sum(),rows.stream().mapToLong(TripOperationsModels.Row::palletSpaces).sum(), rows.stream().map(TripOperationsModels.Row::physicalPallets).filter(Objects::nonNull).mapToLong(Long::longValue).sum(),rows.stream().map(TripOperationsModels.Row::palletSpaces).filter(Objects::nonNull).mapToLong(Long::longValue).sum(),
rows.stream().mapToLong(TripOperationsModels.Row::ownPackages).sum(),rows.stream().mapToLong(TripOperationsModels.Row::externalPackages).sum(),amounts(money),costSummary(rows,costVisible)); rows.stream().mapToLong(TripOperationsModels.Row::ownPackages).sum(),rows.stream().mapToLong(TripOperationsModels.Row::externalPackages).sum(),amounts(money),costSummary(rows,costVisible));
} }
private static TripOperationsModels.CostSummary costSummary(List<TripOperationsModels.Row> rows,boolean visible) { private static TripOperationsModels.CostSummary costSummary(List<TripOperationsModels.Row> rows,boolean visible) {
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*;
import com.baiyue.lastleg.procurement.service.*;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
/** 客户只读取并确认自己的采购实收,不能代替平台作业。 */
@RestController @RequiredArgsConstructor
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
public class MerchantProcurementWorkflowController {
private final ProcurementWorkflowService workflow;
private final OverseasProcurementService orders;
/** 查询当前客户采购批次。 */
@GetMapping("/{orderNo}/workflow")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')")
public ApiResponse<Workflow> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo) { return ApiResponse.success(workflow.read(customer(user),orderNo)); }
/** 确认收货照片与实收数量。 */
@PostMapping("/{orderNo}/confirm")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_CREATE+"')")
public ApiResponse<Workflow> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(workflow.confirm(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request));
}
/** 只下载本单收货与出库已关联凭证,其他内部文件不外露。 */
@GetMapping("/{orderNo}/files/{fileId}/content")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')")
public ResponseEntity<byte[]> file(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@PathVariable long fileId) {
String customer=customer(user);workflow.requireVisibleFile(customer,orderNo,fileId);
var file=orders.downloadFile(customer,orderNo,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(file.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()).body(file.content());
}
/** 客户作用域仅由认证身份解析,不接收请求客户ID。 */
private String customer(AuthenticatedUser user) {
if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(user.tenantId()<=0 || "PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return String.valueOf(user.tenantId());
}
}
@@ -1,77 +0,0 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import com.baiyue.lastleg.procurement.service.ProcurementChargeService;
import com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementChargeController {
private final ProcurementChargeService service;
private final ProcurementAdjustmentService adjustments;
@GetMapping("/{orderNo}/charges")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<ProcurementChargeService.Snapshot> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
@GetMapping("/{orderNo}/fee-corrections")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<java.util.List<ProcurementAdjustmentService.Result>> corrections(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(adjustments.history(customerId,orderNo));
}
@PostMapping("/{orderNo}/charges/confirm")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_CHARGE+"')")
public ApiResponse<ProcurementChargeService.Snapshot> confirm(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Confirm request) {
return ApiResponse.success(service.confirm(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/fees/additional")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<OverseasProcurementFee> additional(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Additional request) {
return ApiResponse.success(adjustments.additional(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/fees/{feeId}/retry")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ProcurementChargeService.Snapshot> retry(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) {
String operator=platform(user,customerId);String permission=service.retryPermission(customerId,orderNo,feeId);
if(user.permissions()==null || !user.permissions().contains(permission))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该费用用途的重试权限");
return ApiResponse.success(service.retry(customerId,orderNo,operator,feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/terminate")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<ProcurementChargeService.Snapshot> terminate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.terminate(customerId,orderNo,platform(user,customerId),feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/release-estimate")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<ProcurementChargeService.Snapshot> releaseEstimate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.releaseEstimate(customerId,orderNo,platform(user,customerId),feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/correct-target")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<ProcurementAdjustmentService.Result> correct(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Correct request) {
return ApiResponse.success(adjustments.correct(customerId,orderNo,platform(user,customerId),feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/replace-failed")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<ProcurementAdjustmentService.Result> replaceFailed(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Correct request) {
return ApiResponse.success(adjustments.replaceFailed(customerId,orderNo,platform(user,customerId),feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/refund")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<OverseasProcurementFee> refund(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Refund request) {
return ApiResponse.success(adjustments.refund(customerId,orderNo,platform(user,customerId),feeId,request));
}
}
@@ -1,45 +0,0 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementControlService;
import com.baiyue.lastleg.procurement.service.ProcurementControlService.Snapshot;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementControlController {
private final ProcurementControlService service;
@GetMapping("/{orderNo}/controls")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Snapshot> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
@PostMapping("/{orderNo}/control-notes")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> note(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Note request) {
return ApiResponse.success(service.note(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/control-notes/{noteId}/resolve")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> resolve(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long noteId,@Valid @RequestBody Resolve request) {
return ApiResponse.success(service.resolve(customerId,orderNo,platform(user,customerId),noteId,request));
}
@PostMapping("/{orderNo}/actual-warehouse/correct")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> warehouse(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody WarehouseCorrection request) {
return ApiResponse.success(service.correctWarehouse(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/exception-close")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> close(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Close request) {
return ApiResponse.success(service.close(customerId,orderNo,platform(user,customerId),request));
}
}
@@ -15,7 +15,7 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Set; import java.util.Set;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; import static com.baiyue.lastleg.procurement.controller.ProcurementPlatformIdentity.platform;
@RestController @RequiredArgsConstructor @RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") @RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
@@ -31,8 +31,8 @@ public class ProcurementFileController {
case BUDGET->Set.of(OverseasPermissions.PURCHASE_REVIEW); case BUDGET->Set.of(OverseasPermissions.PURCHASE_REVIEW);
case LABEL->Set.of(OverseasPermissions.PURCHASE_LABEL); case LABEL->Set.of(OverseasPermissions.PURCHASE_LABEL);
case EXCEPTION->Set.of(OverseasPermissions.PURCHASE_MANAGE); case EXCEPTION->Set.of(OverseasPermissions.PURCHASE_MANAGE);
case RECEIPT->Set.of(OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS,OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC); case RECEIPT->Set.of(OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS,OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC,OverseasPermissions.PURCHASE_SHIP_CUSTOMER);
case VOUCHER->Set.of(OverseasPermissions.PURCHASE_CHARGE,OverseasPermissions.PURCHASE_FINANCE_ADJUST); case VOUCHER->Set.of(OverseasPermissions.PURCHASE_CHARGE,OverseasPermissions.PURCHASE_FINANCE_ADJUST,OverseasPermissions.PURCHASE_SHIP_CUSTOMER);
}; };
if(user.permissions()==null || allowed.stream().noneMatch(user.permissions()::contains))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该采购文件用途的上传权限"); if(user.permissions()==null || allowed.stream().noneMatch(user.permissions()::contains))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该采购文件用途的上传权限");
return ApiResponse.success(service.uploadFile(customerId,orderNo,operator,kind.name(),file)); return ApiResponse.success(service.uploadFile(customerId,orderNo,operator,kind.name(),file));
@@ -1,64 +0,0 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService;
import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.Outcome;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementLifecycleController {
private final ProcurementLifecycleService service;
@GetMapping("/{orderNo}/fulfillment")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Outcome> detail(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId); return ApiResponse.success(service.read(customerId,orderNo));
}
@PostMapping("/{orderNo}/purchase")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_BUY+"')")
public ApiResponse<Outcome> purchase(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Purchase request) {
return ApiResponse.success(service.recordPurchase(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/purchase/confirm")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_BUY+"')")
public ApiResponse<Outcome> confirmPurchase(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(service.confirmPurchase(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/budget-authorizations")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_REVIEW+"')")
public ApiResponse<Outcome> budget(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Budget request) {
return ApiResponse.success(service.authorizeBudget(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/planned-warehouse")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_REVIEW+"')")
public ApiResponse<Outcome> warehouse(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Warehouse request) {
return ApiResponse.success(service.selectWarehouse(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/cancel")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_CANCEL+"')")
public ApiResponse<Outcome> cancel(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(service.cancel(customerId,orderNo,platform(user,customerId),request));
}
static String platform(AuthenticatedUser user,String customerId) {
if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(user.tenantId()!=0 || !"PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
if(customerId==null || !customerId.matches("[1-9][0-9]*")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请指定采购订单客户");
return user.userId()==null?user.username():String.valueOf(user.userId());
}
}
@@ -1,82 +0,0 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService;
import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService.Snapshot;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementPhysicalController {
private final ProcurementPhysicalService service;
@GetMapping("/{orderNo}/physical")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Snapshot> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
@PostMapping("/{orderNo}/overseas-receipts")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Snapshot> overseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.receive(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/domestic-receipts")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')")
public ApiResponse<Snapshot> domestic(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.receive(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/overseas-receipts/complete")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Snapshot> completeOverseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody FinishReceipt request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.finishReceipt(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/domestic-receipts/complete")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')")
public ApiResponse<Snapshot> completeDomestic(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody FinishReceipt request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.finishReceipt(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/dispositions")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> dispose(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Disposition request) {
return ApiResponse.success(service.dispose(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/packing")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_PACK+"')")
public ApiResponse<Snapshot> packing(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Packing request) {
return ApiResponse.success(service.savePacking(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/packing/confirm")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_PACK+"')")
public ApiResponse<Snapshot> confirmPacking(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ConfirmPacking request) {
return ApiResponse.success(service.confirmPacking(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/packing/withdraw")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> withdraw(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(service.withdrawPacking(customerId,orderNo,platform(user,customerId),request));
}
private static void leg(Leg actual,Leg expected){if(actual!=expected)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"收货段别与操作入口不一致");}
@PostMapping("/{orderNo}/receipts/{receiptId}/correct")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> correct(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,
@PathVariable long receiptId,@Valid @RequestBody Correction request) {
return ApiResponse.success(service.correctReceipt(customerId,orderNo,platform(user,customerId),receiptId,request));
}
@PostMapping("/{orderNo}/dispositions/{dispositionId}/revoke")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> revoke(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,
@PathVariable long dispositionId,@Valid @RequestBody RevokeDisposition request) {
return ApiResponse.success(service.revokeDisposition(customerId,orderNo,platform(user,customerId),dispositionId,request));
}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/** 采购平台操作统一验证平台主体和明确客户范围。 */
final class ProcurementPlatformIdentity {
private ProcurementPlatformIdentity() { }
/** 客户身份不能借平台操作权限越权,客户编号不能为空或平台作用域。 */
static String platform(AuthenticatedUser user, String customerId) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.tenantId() != 0 || !"PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
if (customerId == null || !customerId.matches("[1-9][0-9]*")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请指定采购订单客户");
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -1,59 +0,0 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg;
import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementTransportService;
import com.baiyue.lastleg.procurement.service.ProcurementTransportService.Snapshot;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementTransportController {
private final ProcurementTransportService service;
@GetMapping("/{orderNo}/transport")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Snapshot> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
@PostMapping("/{orderNo}/labels")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LABEL+"')")
public ApiResponse<Snapshot> labels(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Labels request) {
return ApiResponse.success(service.saveLabels(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/international-estimate")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_ESTIMATE+"')")
public ApiResponse<Snapshot> estimate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Estimate request) {
return ApiResponse.success(service.reviewEstimate(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/labels/apply")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_APPLY_LABEL+"')")
public ApiResponse<Snapshot> apply(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ApplyLabels request) {
return ApiResponse.success(service.applyLabels(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/domestic-repack")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')")
public ApiResponse<Snapshot> repack(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Repack request) {
return ApiResponse.success(service.repack(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/overseas-shipment")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_OVERSEAS+"')")
public ApiResponse<Snapshot> overseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Ship request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.ship(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/customer-shipment")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Snapshot> customer(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Ship request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.ship(customerId,orderNo,operator,request));
}
private static void leg(Leg actual,Leg expected){if(actual!=expected)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"运输段与操作入口不一致");}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*;
import com.baiyue.lastleg.procurement.service.ProcurementWorkflowService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import static com.baiyue.lastleg.procurement.controller.ProcurementPlatformIdentity.platform;
/** 平台简化采购作业,所有写入沿用正式收货及出库权限。 */
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementWorkflowController {
private final ProcurementWorkflowService service;
/** 查询客户采购批次与库存。 */
@GetMapping("/{orderNo}/workflow")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Workflow> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
/** 录入分批实收照片和数量。 */
@PostMapping("/{orderNo}/receipts")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Workflow> receive(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) {
return ApiResponse.success(service.receive(customerId,orderNo,platform(user,customerId),request));
}
/** 将本单实收交给客户确认。 */
@PostMapping("/{orderNo}/request-confirmation")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Workflow> requestConfirmation(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(service.requestConfirmation(customerId,orderNo,platform(user,customerId),request));
}
/** 从采购库存创建出库单。 */
@PostMapping("/{orderNo}/outbounds")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Workflow> outbound(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Outbound request) {
return ApiResponse.success(service.outbound(customerId,orderNo,platform(user,customerId),request));
}
/** 推进具体出库单到派送中。 */
@PostMapping("/{orderNo}/outbounds/{batchId}/dispatch")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Workflow> dispatch(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.dispatch(customerId,orderNo,batchId,platform(user,customerId),request));
}
/** 上传签收凭证并完成本批出库。 */
@PostMapping("/{orderNo}/outbounds/{batchId}/complete")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Workflow> complete(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Completion request) {
return ApiResponse.success(service.complete(customerId,orderNo,batchId,platform(user,customerId),request));
}
}
@@ -1,35 +0,0 @@
package com.baiyue.lastleg.procurement.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public final class ProcurementChargeRequests {
private ProcurementChargeRequests() { }
public record Amount(@NotBlank @Pattern(regexp="POSITIVE|ZERO|NOT_APPLICABLE") String determination,
@NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency,@NotNull LocalDate businessDate,
@Positive Long voucherFileId,@NotBlank @Size(max=500) String reason) { }
/** Existing immutable fee OR a newly determined amount; never both. */
public record Selection(@Positive Long feeId,@Valid Amount value) { }
public record Other(@NotBlank @Pattern(regexp="[A-Z][A-Z0-9_]{0,31}") String code,
@NotBlank @Size(max=128) String name,@NotNull @Valid Selection selection) { }
public record Confirm(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull @Valid Selection internationalFreight,@NotNull @Valid Selection duty,
@NotNull @Valid Selection domesticFreight,
@NotBlank @Pattern(regexp="NOT_APPLICABLE|ITEMIZED") String otherDetermination,
@NotNull List<@NotNull @Valid Other> otherFees,@NotBlank @Size(max=1000) String reason) { }
public record Additional(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotBlank @Size(max=128) String name,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=16,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency,@NotNull @PastOrPresent LocalDate businessDate,
@Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { }
public record Correct(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal targetOriginalAmount,
@Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { }
public record Refund(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=16,fraction=2) BigDecimal amount,
@Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { }
}

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