Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd25de906 | ||
|
|
849f8dc5f2 | ||
|
|
1b27d7e6c2 | ||
|
|
353c86f4cf | ||
|
|
71a1026e21 | ||
|
|
f3d57869f2 | ||
|
|
7086713fdd | ||
|
|
66b0935b11 | ||
|
|
4f2d90b490 | ||
|
|
96ffc0925a | ||
|
|
ca61f79b6d | ||
|
|
b2e4f0d246 | ||
|
|
0b13c2cfb7 | ||
|
|
c20107c278 | ||
|
|
f418f9a05f | ||
|
|
705c732ffd | ||
|
|
db0e72daeb | ||
|
|
423e40e6aa | ||
|
|
57cd211973 | ||
|
|
ab457e022d | ||
|
|
ed6a173b86 | ||
|
|
fe89b8a2b8 | ||
|
|
83f2fda4cd | ||
|
|
00616d5930 | ||
|
|
3e0d8d6c88 | ||
|
|
a5befc9bad | ||
|
|
f6d9991955 | ||
|
|
c2735cd906 | ||
|
|
2927fbee06 | ||
|
|
be9f5d2a30 | ||
|
|
6577b2aa03 | ||
|
|
ca02ed0c17 | ||
|
|
1928d2e5cf | ||
|
|
e9ef12f078 | ||
|
|
5eabfa395b | ||
|
|
5b53be23e8 | ||
|
|
c6bc3ac85b | ||
|
|
5ad599bd66 | ||
|
|
e27881808d | ||
|
|
9f7cac22b3 | ||
|
|
3f8a0df433 | ||
|
|
17cf05c4e1 | ||
|
|
ba0bf9ebb7 | ||
|
|
8d211acf5b | ||
|
|
f37d52aead | ||
|
|
98f7d81bb1 | ||
|
|
294bfb6020 | ||
|
|
761fe3686e | ||
|
|
5e9cebcf85 | ||
|
|
0fe1d10566 | ||
|
|
50687c851d | ||
|
|
2ae8e7ce94 | ||
|
|
e31dcf80f9 | ||
|
|
2573bd92cb | ||
|
|
20b2a7e058 | ||
|
|
c0cd2c2183 | ||
|
|
46d359241f | ||
|
|
57fcf295b1 | ||
|
|
5950806924 | ||
|
|
4dc3d1752e | ||
|
|
f60f44793f | ||
|
|
d4eb0ef023 | ||
|
|
29bbd85ed6 | ||
|
|
f010dcc4e0 | ||
|
|
7151222836 | ||
|
|
4825f782a3 | ||
|
|
a7ca6b98f4 |
+11
-2
@@ -1,4 +1,13 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
/** Finance-issued identity of a successful consumption, carried by its immutable fee source. */
|
/** 成功扣款凭据;资金业务身份独立于费用对应的实际服务订单。 */
|
||||||
public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId) { }
|
public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId,
|
||||||
|
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
|
||||||
|
String walletBusinessType,
|
||||||
|
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
|
||||||
|
String walletBusinessNo) {
|
||||||
|
/** 原有资金身份与服务订单一致的来源继续使用原三项凭据。 */
|
||||||
|
public WalletSettlementReference(String commandEventId, String holdId, String ledgerId) {
|
||||||
|
this(commandEventId, holdId, ledgerId, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 国内与海外业务单元共享的稳定事件契约。
|
* 国内与海外业务单元共享的稳定事件契约。
|
||||||
|
*
|
||||||
|
* <p>海外费用的 walletSettlement 引用成功扣款的 commandEventId、holdId、ledgerId。
|
||||||
|
* 独立资金流程另外携带 walletBusinessType、walletBusinessNo:人工费用为
|
||||||
|
* OVERSEAS_MANUAL_FEE / 来源费用ID-冻结次数,仓租为 STORAGE_RENT / 出库预占ID。
|
||||||
|
* 外层 businessType、businessNo 始终表示实际服务订单,不能替换为资金流程编号。
|
||||||
|
* Finance 同时核对冻结、实扣流水、成功指令结果及客户金额,一个 hold 只能关联一个费用来源。
|
||||||
|
* 资金身份与服务订单一致的既有引用省略这两个字段,保持既有快照哈希。</p>
|
||||||
*/
|
*/
|
||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class WalletSettlementReferenceTest {
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldReferencesRetainExactHashShape() throws Exception {
|
||||||
|
String original = "{\"commandEventId\":\"c1\",\"holdId\":\"9\",\"ledgerId\":\"72\"}";
|
||||||
|
var reference = json.readValue(original, WalletSettlementReference.class);
|
||||||
|
assertThat(json.readTree(json.writeValueAsString(reference))).isEqualTo(json.readTree(original));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dedicatedFundingIdentitySurvivesRoundTrip() throws Exception {
|
||||||
|
var reference = new WalletSettlementReference("c1", "9", "72", "STORAGE_RENT", "445");
|
||||||
|
assertThat(json.readValue(json.writeValueAsString(reference), WalletSettlementReference.class)).isEqualTo(reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 海外费用与仓租数据库定义汇总
|
||||||
|
|
||||||
|
本次功能的数据库定义已统一到下面两份 SQL,五份重复版本脚本和两份专项汇总 SQL 已移除。仅整理文件,未连接数据库、执行 SQL 或部署。
|
||||||
|
|
||||||
|
## 两个统一入口
|
||||||
|
|
||||||
|
| 文件 | 相关数据库 | 本次内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| [头程数据库统一增量-MySQL8.sql](头程数据库统一增量-MySQL8.sql) | `baiyue_iam` | 海外费用管理菜单及默认平台管理员授权;保留既有财务编辑、审核按钮和授权 |
|
||||||
|
| [海外区域统一增量-MySQL8.sql](海外区域统一增量-MySQL8.sql) | `baiyue_lastleg` | 资金与仓租四表、费用关联及换标快照、审核人名称、采购退款请求幂等字段与唯一索引 |
|
||||||
|
|
||||||
|
两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `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 隐式提交,不能宣称整体可回滚;失败保留错误并只读复查,已完成项不删除重建。两步是已核缺项的补齐方案,不是任意版本升级工具;重新执行前仍需核对目标实例与结构。
|
||||||
|
|
||||||
|
## 海外表和字段
|
||||||
|
|
||||||
|
| 类型 | 表 / 字段 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| 新表 | `ll_finance_fee_funding` | 人工费用冻结、审核扣款、释放及财务流水引用 |
|
||||||
|
| 新表 | `ll_storage_rent_lot` | 实际入库计租原批次、数量及入库时间 |
|
||||||
|
| 新表 | `ll_storage_rent_inbound` | 头程每次实收凭证去重,关联同单同仓计租批次 |
|
||||||
|
| 新表 | `ll_storage_rent_share` | 库存与原计租批次份额关联,保留原库龄 |
|
||||||
|
| 新表 | `ll_storage_rent_preparation` | 出库计租快照、汇率、资金预占及实际出库记录 |
|
||||||
|
| 可空字段 | `ll_destuff_pallet_cost_allocation.fee_source_id` | `BIGINT NULL`,允许人工审核后关联海外费用来源 |
|
||||||
|
| 字段 | `ll_return_sku_conversion.storage_rent_shares_json` | `LONGTEXT NULL`,退货 SKU 换标的原仓租份额快照 |
|
||||||
|
| 字段 | `ll_finance_fee_source.reviewer_name` | `VARCHAR(255) NULL`,审核人名称快照 |
|
||||||
|
| 字段 | `ll_overseas_procurement_fee.idempotency_key` | `VARCHAR(128) NULL`,采购退款请求幂等键 |
|
||||||
|
| 唯一索引 | `uk_ll_procurement_refund_request(order_id,purpose,idempotency_key)` | 同一采购订单、用途和退款请求去重 |
|
||||||
|
|
||||||
|
## 国内菜单和权限
|
||||||
|
|
||||||
|
海外业务 → 海外费用管理,菜单 ID `5117200`、权限 `platform:overseas-fee:list`、路由 `/overseas-business/fees`;默认仅授予 `PLATFORM_ADMIN`。编辑和审核分别复用 `platform:finance:fee-source:edit` 与 `platform:finance:fee-source:review`,列表权限不包含写操作。国内基线已包含两项既有按钮及管理员授权。
|
||||||
|
|
||||||
|
已有库的 [费用写权限修复](overseas-fee-canonical-write-permissions-repair.sql) 是单独的定向补丁,继续保留原适用条件和授权边界,不是结构升级或菜单初始化入口。其他独立修复脚本与历史迁移不在本次清理范围。
|
||||||
|
|
||||||
|
费用项目继续使用 Firstleg 现有目录及 `FEE_TYPE_077 / 仓储费`,不重复插入项目、不批量覆盖客户专价、不回填历史仓租或费用、不修改钱包余额。
|
||||||
|
|
||||||
|
配套服务、页面、配置及验收边界见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。
|
||||||
+37
-2
@@ -1,9 +1,35 @@
|
|||||||
# 单测试环境数据库空库初始化
|
# 单测试环境数据库空库初始化
|
||||||
|
|
||||||
|
`migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql` 修复海外人工费用资金重试报 S0500:仅将 `baiyue_lastleg.ll_finance_fee_command.action` 扩为 `VARCHAR(32)`,容纳 20 字符的 `MANUAL_FUNDING_RETRY`,保留中文备注、非空及原幂等唯一键。Lastleg 源基线和海外空库基线同步。已有环境先核对目标库及列定义,经授权执行 V35、回查列长度和记录总数,再通过原费用重试入口验证;可重复执行,不修改费用、审核、余额或资金记录,不重放空库基线。DDL 隐式提交,遇错停止;本次仅准备修复文件,尚未执行测试环境 SQL 或部署,文件存在不表示已经执行。
|
||||||
|
|
||||||
|
`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。
|
||||||
|
|
||||||
|
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
|
||||||
|
|
||||||
|
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
|
||||||
|
|
||||||
|
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
|
||||||
|
|
||||||
|
`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。
|
||||||
|
|
||||||
|
`overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。
|
||||||
|
|
||||||
|
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
|
||||||
|
|
||||||
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
||||||
|
|
||||||
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
||||||
|
|
||||||
|
船节点日志在 `fl_carrier_schedule_node_fact.operator_id/operator_name` 保存操作时的账号与用户名快照;人工补录和确认出仓写入,船司自动采集为空。两列定义同步于 Firstleg 船节点模块基线与国内统一空库基线,更新结构后配套发布 Firstleg 与 Admin。既有 TEST 的字段变更须单独授权执行,不重放空库初始化,不回填历史节点。
|
||||||
|
|
||||||
|
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
|
||||||
|
|
||||||
|
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
|
||||||
|
|
||||||
|
`finance-overseas-fee-hash-width-repair.sql` 用于明确授权的本机开发环境海外费用推送修复,固定目标 `baiyue_finance`。先核对 `fin_inbox_event.payload_hash`、`fin_fee_source.source_hash` 两列;仅将不足71位的长度扩至71位,兼容 `sha256:` 前缀,保留空值约束和字符集排序规则,不更新业务数据。缺列、异常类型或小于64位时停止;已有足够长度则跳过。使用独立连接、遇错停止,执行后回查结构,再通过原费用事件重试验证,禁止重新生成费用或扣款。脚本包含执行后移除的专用校验过程;创建或执行失败须停止,不扩大修复范围。文件准备不表示已执行。
|
||||||
|
|
||||||
|
`warehouse-supplier-query-structure-repair.sql` 用于明确授权的本机开发环境供应商查询结构修复,固定目标 `baiyue_warehouse`:仅在缺失时新增 `wh_supplier.trucking_scope`,并按当前模块基线创建缺失的 `wh_supplier_carrier` 空表。结构存在则跳过,不更新供应商类别、费用或资金,不推断历史拖车的国内/海外归属。使用独立连接、遇错停止,执行后回查结构并通过正式供应商列表 API 验证;不需要重启服务。文件准备不表示已获授权或已执行。
|
||||||
|
|
||||||
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
|
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
|
||||||
|
|
||||||
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
|
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
|
||||||
@@ -69,7 +95,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` | 234 |
|
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 222 |
|
||||||
|
|
||||||
|
|
||||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||||
@@ -92,7 +118,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/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/222`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||||
|
|
||||||
## 平台管理员密码
|
## 平台管理员密码
|
||||||
|
|
||||||
@@ -192,3 +218,12 @@ 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 换标仓租快照及审核人名称;采购旧费用与退款专属表已在简化流程退役,不再进入空库初始化。同步模块源基线、国内权限种子及海外空库 222 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。
|
||||||
|
|
||||||
|
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
|
||||||
|
|
||||||
|
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
|
||||||
|
|
||||||
|
|
||||||
|
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- 本机开发环境海外费用推送结构修复。固定目标:baiyue_finance。
|
||||||
|
-- 仅将两个既有摘要字段不足 71 位的长度扩至 71;不改数据、费用、账单或余额。
|
||||||
|
-- 保留原字段的空值约束、字符集及排序规则;缺表、缺列或异常字段类型时拒绝执行。
|
||||||
|
-- 执行须单独授权。使用独立连接、遇错停止;不得以本文件代替完整数据库初始化。
|
||||||
|
USE baiyue_finance;
|
||||||
|
|
||||||
|
-- 执行前只读核对;sha256: 前缀 7 位加摘要 64 位,共 71 位。
|
||||||
|
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
|
||||||
|
CHARACTER_SET_NAME, COLLATION_NAME
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'baiyue_finance'
|
||||||
|
AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash')
|
||||||
|
OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash'));
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE finance_fee_hash_width_repair_20261023()
|
||||||
|
BEGIN
|
||||||
|
DECLARE checked_columns INT DEFAULT 0;
|
||||||
|
DECLARE column_index INT DEFAULT 0;
|
||||||
|
DECLARE target_table VARCHAR(64);
|
||||||
|
DECLARE target_column VARCHAR(64);
|
||||||
|
DECLARE current_length BIGINT;
|
||||||
|
DECLARE nullable_value VARCHAR(3);
|
||||||
|
DECLARE charset_value VARCHAR(64);
|
||||||
|
DECLARE collation_value VARCHAR(64);
|
||||||
|
DECLARE comment_value TEXT;
|
||||||
|
|
||||||
|
IF DATABASE() <> 'baiyue_finance' THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '目标必须是 baiyue_finance';
|
||||||
|
END IF;
|
||||||
|
SELECT COUNT(*) INTO checked_columns
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'baiyue_finance' AND DATA_TYPE IN ('char', 'varchar')
|
||||||
|
AND CHARACTER_MAXIMUM_LENGTH >= 64
|
||||||
|
AND COLUMN_DEFAULT IS NULL AND EXTRA = ''
|
||||||
|
AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash')
|
||||||
|
OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash'));
|
||||||
|
IF checked_columns <> 2 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '摘要字段缺失或类型异常,停止修复';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
WHILE column_index < 2 DO
|
||||||
|
SET target_table = IF(column_index = 0, 'fin_inbox_event', 'fin_fee_source');
|
||||||
|
SET target_column = IF(column_index = 0, 'payload_hash', 'source_hash');
|
||||||
|
SELECT CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_COMMENT
|
||||||
|
INTO current_length, nullable_value, charset_value, collation_value, comment_value
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'baiyue_finance' AND TABLE_NAME = target_table
|
||||||
|
AND COLUMN_NAME = target_column;
|
||||||
|
IF current_length < 71 THEN
|
||||||
|
SET @finance_fee_hash_width_ddl = CONCAT(
|
||||||
|
'ALTER TABLE baiyue_finance.`', target_table, '` MODIFY COLUMN `', target_column,
|
||||||
|
'` VARCHAR(71) CHARACTER SET ', charset_value, ' COLLATE ', collation_value,
|
||||||
|
IF(nullable_value = 'YES', ' NULL', ' NOT NULL'),
|
||||||
|
' COMMENT ', QUOTE(comment_value));
|
||||||
|
PREPARE finance_fee_hash_width_stmt FROM @finance_fee_hash_width_ddl;
|
||||||
|
EXECUTE finance_fee_hash_width_stmt;
|
||||||
|
DEALLOCATE PREPARE finance_fee_hash_width_stmt;
|
||||||
|
END IF;
|
||||||
|
SET column_index = column_index + 1;
|
||||||
|
END WHILE;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CALL finance_fee_hash_width_repair_20261023();
|
||||||
|
DROP PROCEDURE finance_fee_hash_width_repair_20261023;
|
||||||
|
SET @finance_fee_hash_width_ddl = NULL;
|
||||||
|
|
||||||
|
-- 执行后两列长度均应至少为 71;结构已正确时不执行 ALTER。
|
||||||
|
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
|
||||||
|
CHARACTER_MAXIMUM_LENGTH >= 71 AS width_ok
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'baiyue_finance'
|
||||||
|
AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash')
|
||||||
|
OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash'));
|
||||||
@@ -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,16 @@
|
|||||||
|
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
||||||
|
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||||
|
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||||
|
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||||
|
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||||
|
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||||
|
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||||
|
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||||
|
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||||
|
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||||
|
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
||||||
|
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||||
|
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||||
|
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||||
|
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||||
|
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||||
|
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||||
|
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||||
|
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||||
|
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||||
|
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- 先于新版本 Lastleg 上线执行。历史订单保持海外采购回国内,不改数量、资金或交寄事实。
|
||||||
|
SET @procurement_direction_ddl = IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'll_overseas_procurement_order' AND COLUMN_NAME = 'procurement_direction') = 0,
|
||||||
|
'ALTER TABLE ll_overseas_procurement_order ADD COLUMN procurement_direction VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS_TO_DOMESTIC'' COMMENT ''采购方向:海外采购回国内/国内采购发海外''',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE procurement_direction_stmt FROM @procurement_direction_ddl;
|
||||||
|
EXECUTE procurement_direction_stmt;
|
||||||
|
DEALLOCATE PREPARE procurement_direction_stmt;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- 海外费用资金重试动作长度修复:兼容 MANUAL_FUNDING_RETRY 等费用幂等动作。
|
||||||
|
ALTER TABLE baiyue_lastleg.ll_finance_fee_command
|
||||||
|
MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试';
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
-- 海外费用既有写按钮最小修复;MySQL 8,目标只能是 baiyue_iam。
|
||||||
|
-- 仅恢复基线 5105024/5105025 及既有 4000001 PLATFORM_ADMIN 缺失授权。
|
||||||
|
-- 不创建角色/成员,不授予其他角色,不新增 overseas-fee:edit/review 别名。
|
||||||
|
-- 在支持 DELIMITER、遇错停止的客户端同连接整份执行;执行期间暂停相关角色权限维护。
|
||||||
|
-- 不在其他未提交事务中调用。所有业务DML及subject版本变更同事务,任何异常回滚。
|
||||||
|
USE baiyue_iam;
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SELECT DATABASE() AS target_database;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS repair_overseas_fee_canonical_write;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE repair_overseas_fee_canonical_write()
|
||||||
|
BEGIN
|
||||||
|
DECLARE locked_role BIGINT;
|
||||||
|
DECLARE changed_rows BIGINT DEFAULT 0;
|
||||||
|
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
RESIGNAL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
START TRANSACTION;
|
||||||
|
IF DATABASE()<>'baiyue_iam' THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Wrong schema: expected baiyue_iam';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM iam_role WHERE id=4000001 AND tenant_id=0 AND app_code='PLATFORM'
|
||||||
|
AND role_code='PLATFORM_ADMIN' AND role_type='SYSTEM' AND status='ACTIVE' AND deleted=0)<>1
|
||||||
|
OR EXISTS(SELECT 1 FROM iam_role WHERE role_code='PLATFORM_ADMIN' AND id<>4000001) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing PLATFORM_ADMIN identity/status mismatch; no role will be created';
|
||||||
|
END IF;
|
||||||
|
SELECT id INTO locked_role FROM iam_role WHERE id=4000001 FOR UPDATE;
|
||||||
|
|
||||||
|
-- 新建缺失按钮使用当前客户应收父菜单;只接受已核对的既有财务菜单,不修复其他菜单。
|
||||||
|
IF (SELECT COUNT(*) FROM iam_permission WHERE id=5105041 AND tenant_id=0 AND app_code='PLATFORM'
|
||||||
|
AND permission_type='MENU' AND permission_code='platform:finance:customer-arap:list'
|
||||||
|
AND status='ACTIVE' AND deleted=0)<>1
|
||||||
|
OR EXISTS(SELECT 1 FROM iam_permission WHERE permission_code='platform:finance:customer-arap:list' AND id<>5105041) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing customer AR/AP parent menu is missing or mismatched';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM iam_permission WHERE
|
||||||
|
(id=5105024 AND NOT(permission_code<=>'platform:finance:fee-source:review'))
|
||||||
|
OR (id=5105025 AND NOT(permission_code<=>'platform:finance:fee-source:edit'))
|
||||||
|
OR (permission_code='platform:finance:fee-source:review' AND id<>5105024)
|
||||||
|
OR (permission_code='platform:finance:fee-source:edit' AND id<>5105025)
|
||||||
|
OR (id IN(5105024,5105025) AND (tenant_id<>0 OR app_code<>'PLATFORM'
|
||||||
|
OR permission_type<>'BUTTON' OR built_in<>1 OR parent_id NOT IN(5105000,5105041)
|
||||||
|
OR route_path IS NOT NULL
|
||||||
|
OR NOT(http_method<=>IF(id=5105024,'POST','PUT'))))) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Canonical fee button identity/type/metadata conflict';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM iam_permission child LEFT JOIN iam_permission parent ON parent.id=child.parent_id
|
||||||
|
WHERE child.id IN(5105024,5105025) AND (parent.id IS NULL OR parent.tenant_id<>0
|
||||||
|
OR parent.app_code<>'PLATFORM' OR parent.permission_type<>'MENU' OR parent.status<>'ACTIVE' OR parent.deleted<>0
|
||||||
|
OR NOT(parent.permission_code<=>IF(child.parent_id=5105000,'platform:finance','platform:finance:customer-arap:list')))) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing fee button parent is invalid';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM iam_role_permission WHERE permission_id IN(5105024,5105025) AND tenant_id<>0)
|
||||||
|
OR EXISTS(SELECT 1 FROM iam_membership_role mr LEFT JOIN iam_membership m ON m.id=mr.membership_id
|
||||||
|
WHERE mr.role_id=4000001 AND (mr.tenant_id<>0 OR m.id IS NULL OR m.tenant_id<>0)) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing canonical permission or administrator membership scope mismatch';
|
||||||
|
END IF;
|
||||||
|
-- 恢复停用/缺失资源也会恢复其他角色的旧授权;这种情况必须单独核对,不能随本补丁扩权。
|
||||||
|
IF EXISTS(SELECT 1 FROM iam_role_permission rp LEFT JOIN iam_permission p ON p.id=rp.permission_id
|
||||||
|
WHERE rp.permission_id IN(5105024,5105025) AND rp.role_id<>4000001
|
||||||
|
AND (p.id IS NULL OR p.status<>'ACTIVE' OR p.deleted<>0)) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Restoring fee button would affect another role; review separately';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO iam_permission
|
||||||
|
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||||
|
SELECT 5105024,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:review','审核财务费用来源',NULL,'POST',150,'ACTIVE',1,0
|
||||||
|
WHERE NOT EXISTS(SELECT 1 FROM iam_permission WHERE id=5105024);
|
||||||
|
SET changed_rows=changed_rows+ROW_COUNT();
|
||||||
|
INSERT INTO iam_permission
|
||||||
|
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||||
|
SELECT 5105025,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:edit','编辑财务费用来源',NULL,'PUT',160,'ACTIVE',1,0
|
||||||
|
WHERE NOT EXISTS(SELECT 1 FROM iam_permission WHERE id=5105025);
|
||||||
|
SET changed_rows=changed_rows+ROW_COUNT();
|
||||||
|
UPDATE iam_permission SET status='ACTIVE',deleted=0
|
||||||
|
WHERE id IN(5105024,5105025) AND (status<>'ACTIVE' OR deleted<>0);
|
||||||
|
SET changed_rows=changed_rows+ROW_COUNT();
|
||||||
|
INSERT INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||||
|
SELECT 0,4000001,p.id,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.id IN(5105024,5105025)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
|
SET changed_rows=changed_rows+ROW_COUNT();
|
||||||
|
|
||||||
|
-- 只有真实变更才推进版本;枚举所有既有管理员成员,不依赖当前登录用户,也不遗漏停用成员。
|
||||||
|
IF changed_rows>0 THEN
|
||||||
|
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
|
||||||
|
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
|
||||||
|
JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
|
||||||
|
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id AND mr.role_id=4000001
|
||||||
|
WHERE u.auth_user_id IS NOT NULL
|
||||||
|
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
|
||||||
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id
|
||||||
|
WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.id IN(5105024,5105025)
|
||||||
|
AND p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'
|
||||||
|
AND p.status='ACTIVE' AND p.deleted=0)<>2 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Canonical fee write grants incomplete; repair rolled back';
|
||||||
|
END IF;
|
||||||
|
COMMIT;
|
||||||
|
SELECT changed_rows AS repaired_catalog_or_grant_rows;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL repair_overseas_fee_canonical_write();
|
||||||
|
DROP PROCEDURE repair_overseas_fee_canonical_write;
|
||||||
|
|
||||||
|
-- 核验1:必须恰好两行,角色4000001、tenant=0、BUTTON、ACTIVE、deleted=0。
|
||||||
|
SELECT r.id AS role_id,r.role_code,rp.tenant_id,p.id AS permission_id,p.permission_code,
|
||||||
|
p.permission_type,p.status,p.deleted
|
||||||
|
FROM iam_role r JOIN iam_role_permission rp ON rp.role_id=r.id
|
||||||
|
JOIN iam_permission p ON p.id=rp.permission_id
|
||||||
|
WHERE r.id=4000001 AND p.id IN(5105024,5105025) ORDER BY p.id;
|
||||||
|
-- 核验2:仅查看既有管理员主体与当前版本,不创建或切换任何成员角色。
|
||||||
|
SELECT DISTINCT u.auth_user_id,m.id AS membership_id,u.status AS user_status,m.status AS membership_status,
|
||||||
|
COALESCE(sv.permission_version,0) AS permission_version
|
||||||
|
FROM iam_user u JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
|
||||||
|
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id AND mr.role_id=4000001
|
||||||
|
LEFT JOIN iam_security_subject_version sv ON sv.auth_user_id=u.auth_user_id
|
||||||
|
ORDER BY u.auth_user_id,m.id;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
-- 海外费用菜单定向修复,MySQL 8.0+,目标只能 baiyue_iam。
|
||||||
|
-- 将“海外费用管理”改为“海外费用明细”,并挂到“财务管理”下。
|
||||||
|
-- 权限码、路由和角色授权保持不变;可重复执行。
|
||||||
|
USE baiyue_iam;
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS repair_overseas_fee_menu_parent;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE repair_overseas_fee_menu_parent()
|
||||||
|
BEGIN
|
||||||
|
DECLARE v_menu_changed INT DEFAULT 0;
|
||||||
|
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
RESIGNAL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF (SELECT COUNT(*) FROM iam_permission
|
||||||
|
WHERE id=5105000 AND tenant_id=0 AND app_code='PLATFORM'
|
||||||
|
AND permission_type='MENU' AND permission_code='platform:finance'
|
||||||
|
AND status='ACTIVE' AND deleted=0)<>1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT='Finance parent menu platform:finance is missing or inactive';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM iam_permission
|
||||||
|
WHERE (id=5117200 AND (permission_code<>'platform:overseas-fee:list'
|
||||||
|
OR tenant_id<>0 OR app_code<>'PLATFORM' OR permission_type<>'MENU'))
|
||||||
|
OR (permission_code='platform:overseas-fee:list'
|
||||||
|
AND (id<>5117200 OR tenant_id<>0 OR app_code<>'PLATFORM'
|
||||||
|
OR permission_type<>'MENU'))) THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT='Overseas fee menu permission identity conflict';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (SELECT COUNT(*) FROM iam_permission
|
||||||
|
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
|
||||||
|
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list')<>1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT='Overseas fee menu platform:overseas-fee:list is missing';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
START TRANSACTION;
|
||||||
|
UPDATE iam_permission
|
||||||
|
SET parent_id=5105000,
|
||||||
|
permission_name='海外费用明细',
|
||||||
|
route_path='/overseas-business/fees',
|
||||||
|
http_method='GET',
|
||||||
|
sort_no=60,
|
||||||
|
status='ACTIVE',
|
||||||
|
deleted=0,
|
||||||
|
updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
|
||||||
|
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list'
|
||||||
|
AND (parent_id<>5105000 OR permission_name<>'海外费用明细'
|
||||||
|
OR route_path<>'/overseas-business/fees' OR http_method<>'GET'
|
||||||
|
OR sort_no<>60 OR status<>'ACTIVE' OR deleted<>0);
|
||||||
|
SET v_menu_changed=ROW_COUNT();
|
||||||
|
|
||||||
|
-- 菜单实际变化时递增已获该菜单权限用户的安全版本,刷新旧授权快照。
|
||||||
|
IF v_menu_changed>0 THEN
|
||||||
|
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
|
||||||
|
SELECT DISTINCT u.auth_user_id,1
|
||||||
|
FROM iam_user u
|
||||||
|
JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
|
||||||
|
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0
|
||||||
|
JOIN iam_role r ON r.id=mr.role_id AND r.tenant_id=0
|
||||||
|
JOIN iam_role_permission rp ON rp.role_id=r.id
|
||||||
|
AND rp.permission_id=5117200 AND rp.tenant_id=0
|
||||||
|
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
|
||||||
|
AND m.status='ACTIVE' AND m.deleted=0
|
||||||
|
AND r.app_code='PLATFORM' AND r.status='ACTIVE' AND r.deleted=0
|
||||||
|
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL repair_overseas_fee_menu_parent();
|
||||||
|
DROP PROCEDURE repair_overseas_fee_menu_parent;
|
||||||
|
|
||||||
|
SELECT id,parent_id,permission_code,permission_name,route_path,sort_no
|
||||||
|
FROM iam_permission WHERE id IN (5105000,5117200) ORDER BY id;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 海外人工费用说明字段。仅修改 baiyue_lastleg.ll_finance_fee_source 的结构。
|
||||||
|
-- 执行需单独授权;使用独立连接、遇错停止。先执行本脚本,再加载配套 Lastleg 代码。
|
||||||
|
-- 无业务数据回填、费用重建、资金操作或菜单变更。已有字段时跳过新增。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
|
SET @overseas_fee_remark_ddl = (
|
||||||
|
SELECT IF(EXISTS(
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'll_finance_fee_source' AND column_name = 'fee_remark'
|
||||||
|
), 'SELECT ''fee_remark already exists'' AS result',
|
||||||
|
'ALTER TABLE ll_finance_fee_source ADD COLUMN fee_remark VARCHAR(500) NULL COMMENT ''费用说明,补收或重复作业原因'' AFTER fee_item_name')
|
||||||
|
);
|
||||||
|
PREPARE overseas_fee_remark_stmt FROM @overseas_fee_remark_ddl;
|
||||||
|
EXECUTE overseas_fee_remark_stmt;
|
||||||
|
DEALLOCATE PREPARE overseas_fee_remark_stmt;
|
||||||
|
|
||||||
|
SELECT table_schema, table_name, column_name, column_type, is_nullable, column_comment
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_finance_fee_source' AND column_name = 'fee_remark';
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- 指定 TEST 的海外费用/仓租结构补齐;实例必须由执行者另行核对并获准。
|
||||||
|
-- 仅新增 ll_storage_rent_inbound 和 ll_finance_fee_source.reviewer_name。
|
||||||
|
-- 使用独立连接,支持 DELIMITER,遇错停止;禁止 mysql --force。
|
||||||
|
-- 不更新费用、库存、钱包或历史计租数据,不重放空库初始化。
|
||||||
|
-- DDL 隐式提交;失败先回查结构,禁止删除已建表/字段来重跑。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
|
-- 仅清理本脚本可能遗留的专用校验过程,不删除业务对象。
|
||||||
|
DROP PROCEDURE IF EXISTS overseas_fee_rent_structure_add_20261026;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE overseas_fee_rent_structure_add_20261026()
|
||||||
|
BEGIN
|
||||||
|
DECLARE inbound_exists INT DEFAULT 0;
|
||||||
|
DECLARE reviewer_exists INT DEFAULT 0;
|
||||||
|
|
||||||
|
IF DATABASE()<>'baiyue_lastleg' THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg';
|
||||||
|
END IF;
|
||||||
|
-- 在首个业务 DDL 前核对两张依赖表及新增字段的定位列。
|
||||||
|
IF (SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_finance_fee_source','ll_storage_rent_lot')
|
||||||
|
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB')<>2
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||||
|
AND COLUMN_NAME='reviewer_id' AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=128)<>1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用来源或计租批次基础结构不匹配,停止补齐';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO reviewer_exists FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name';
|
||||||
|
IF reviewer_exists>0 AND (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name'
|
||||||
|
AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=255 AND IS_NULLABLE='YES'
|
||||||
|
AND COLUMN_DEFAULT IS NULL AND EXTRA='')<>1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的审核人名称字段与正式定义不同,停止且不覆盖';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO inbound_exists FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound';
|
||||||
|
-- 已存在的同名表必须完整匹配;不以 IF NOT EXISTS 掩盖半成品或异构表。
|
||||||
|
IF inbound_exists>0 THEN
|
||||||
|
IF (SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
|
||||||
|
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_unicode_ci')<>1
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
|
||||||
|
AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA=''
|
||||||
|
AND ((COLUMN_NAME IN ('receipt_id','lot_id') AND COLUMN_TYPE='bigint')
|
||||||
|
OR (COLUMN_NAME='quantity' AND COLUMN_TYPE='decimal(24,12)')
|
||||||
|
OR (COLUMN_NAME='created_at' AND COLUMN_TYPE='datetime(3)')))<>4 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的计租实收表字段与正式定义不同,停止且不覆盖';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY')<>1
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY'
|
||||||
|
AND COLUMN_NAME='receipt_id' AND SEQ_IN_INDEX=1 AND NON_UNIQUE=0 AND SUB_PART IS NULL)<>1
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
|
||||||
|
AND INDEX_NAME='idx_storage_rent_inbound_lot')<>1
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
|
||||||
|
AND INDEX_NAME='idx_storage_rent_inbound_lot' AND COLUMN_NAME='lot_id'
|
||||||
|
AND SEQ_IN_INDEX=1 AND NON_UNIQUE=1 AND SUB_PART IS NULL)<>1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='计租实收表主键或批次索引不匹配,停止且不覆盖';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 全部前置检查通过后才补缺项;重跑时保持已存在的业务结构和数据。
|
||||||
|
IF reviewer_exists=0 THEN
|
||||||
|
ALTER TABLE ll_finance_fee_source
|
||||||
|
ADD COLUMN reviewer_name VARCHAR(255) NULL COMMENT '审核人名称快照' AFTER reviewer_id;
|
||||||
|
END IF;
|
||||||
|
IF inbound_exists=0 THEN
|
||||||
|
CREATE TABLE ll_storage_rent_inbound (
|
||||||
|
receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量',
|
||||||
|
lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键',
|
||||||
|
quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数',
|
||||||
|
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
|
||||||
|
PRIMARY KEY (receipt_id),
|
||||||
|
KEY idx_storage_rent_inbound_lot (lot_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL overseas_fee_rent_structure_add_20261026();
|
||||||
|
DROP PROCEDURE overseas_fee_rent_structure_add_20261026;
|
||||||
|
|
||||||
|
SHOW CREATE TABLE ll_storage_rent_inbound;
|
||||||
|
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name';
|
||||||
|
SELECT 'TABLE_COLUMN_READY' AS structure_status;
|
||||||
|
-- TABLE_COLUMN_READY 只表示上述表/字段补齐;免费仓租另执行已审阅的
|
||||||
|
-- overseas-storage-rent-zero-fee-repair.sql,不能据此宣称完整功能上线。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因'''));
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照'''));
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
|
||||||
|
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
|
||||||
|
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
|
||||||
|
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
|
||||||
|
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
|
||||||
|
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
|
||||||
|
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
|
||||||
|
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
|
||||||
|
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
|
||||||
|
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
|
||||||
|
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
|
||||||
|
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
|
||||||
|
KEY idx_order_budget_release(status,release_requested,created_at),
|
||||||
|
KEY idx_order_budget_order(customer_id,kind,order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- 海外采购旧流程专属表退役,MySQL 8.0+,目标仅 baiyue_lastleg。
|
||||||
|
-- 必须先停止旧版 Lastleg 写入并部署本次分离版本,验证新流程后在维护窗口执行。
|
||||||
|
-- 永久删除下面明确列出的 21 张旧采购表及其记录,DDL 隐式提交;禁止 mysql --force。
|
||||||
|
-- 保留主单、审核流水、采购附件、分批收货、出库及幂等六表;不改任何共用财务、资金、文件或仓储表。
|
||||||
|
-- 旧资金表有记录或旧状态采购未处理时拒绝,不自动清除、回填或伪造资金完成。
|
||||||
|
-- 运行者须在同一连接显式 SET @procurement_retirement_release='procurement-six-tables-20261027';
|
||||||
|
-- 文件准备不表示数据库已执行。先审核备份及运行版本,按清单整份执行,遇错停止。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
DELIMITER $$
|
||||||
|
DROP PROCEDURE IF EXISTS retire_procurement_legacy_tables$$
|
||||||
|
CREATE PROCEDURE retire_procurement_legacy_tables()
|
||||||
|
BEGIN
|
||||||
|
IF DATABASE()<>'baiyue_lastleg' OR COALESCE(@procurement_retirement_release,'')<>'procurement-six-tables-20261027' THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Confirm the six-table procurement release and target schema first';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN (
|
||||||
|
'll_overseas_procurement_order','ll_overseas_procurement_status_history','ll_overseas_procurement_file',
|
||||||
|
'll_procurement_workflow_receipt','ll_procurement_workflow_outbound','ll_procurement_workflow_command'))<>6 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Six active procurement tables must exist before retiring legacy tables';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM ll_overseas_procurement_order WHERE status NOT IN
|
||||||
|
('PENDING_REVIEW','RECEIVING','WAIT_CUSTOMER_CONFIRM','CONFIRMED','IN_TRANSIT','DELIVERING','COMPLETED','REJECTED','CANCELLED')) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement orders remain; resolve them before retirement';
|
||||||
|
END IF;
|
||||||
|
-- 仅只读核对共用资金记录;不删除资金命令或回执,不把已投递当作已结清。
|
||||||
|
IF EXISTS(SELECT 1 FROM ll_event_outbox WHERE aggregate_type='PROCUREMENT_FEE')
|
||||||
|
OR EXISTS(SELECT 1 FROM ll_wallet_event_inbox
|
||||||
|
WHERE JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.businessType'))='PROCUREMENT_FEE') THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement wallet events exist; financial review is required';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_fee') THEN
|
||||||
|
SET @procurement_retirement_financial_rows=0;
|
||||||
|
SET @procurement_retirement_check_sql='SELECT COUNT(*) INTO @procurement_retirement_financial_rows FROM baiyue_lastleg.ll_overseas_procurement_fee';
|
||||||
|
PREPARE procurement_retirement_check FROM @procurement_retirement_check_sql;
|
||||||
|
EXECUTE procurement_retirement_check;
|
||||||
|
DEALLOCATE PREPARE procurement_retirement_check;
|
||||||
|
IF @procurement_retirement_financial_rows<>0 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement financial records remain; do not delete financial evidence';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_procurement_wallet_attempt') THEN
|
||||||
|
SET @procurement_retirement_financial_rows=0;
|
||||||
|
SET @procurement_retirement_check_sql='SELECT COUNT(*) INTO @procurement_retirement_financial_rows FROM baiyue_lastleg.ll_procurement_wallet_attempt';
|
||||||
|
PREPARE procurement_retirement_check FROM @procurement_retirement_check_sql;
|
||||||
|
EXECUTE procurement_retirement_check;
|
||||||
|
DEALLOCATE PREPARE procurement_retirement_check;
|
||||||
|
IF @procurement_retirement_financial_rows<>0 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement financial records remain; do not delete financial evidence';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
-- 不关闭外键校验;来自其他业务表的外键也会使这条精确删除失败。
|
||||||
|
DROP TABLE IF EXISTS
|
||||||
|
ll_procurement_closure_item,
|
||||||
|
ll_procurement_closure,
|
||||||
|
ll_procurement_control_note,
|
||||||
|
ll_procurement_fee_correction_item,
|
||||||
|
ll_procurement_fee_correction,
|
||||||
|
ll_procurement_repack,
|
||||||
|
ll_procurement_estimate_review,
|
||||||
|
ll_procurement_label_item,
|
||||||
|
ll_procurement_label_set,
|
||||||
|
ll_procurement_shipment_item,
|
||||||
|
ll_procurement_shipment,
|
||||||
|
ll_procurement_carton_source,
|
||||||
|
ll_procurement_carton,
|
||||||
|
ll_procurement_manifest,
|
||||||
|
ll_procurement_disposition,
|
||||||
|
ll_procurement_receipt,
|
||||||
|
ll_procurement_budget_authorization,
|
||||||
|
ll_procurement_command,
|
||||||
|
ll_procurement_flow,
|
||||||
|
ll_procurement_wallet_attempt,
|
||||||
|
ll_overseas_procurement_fee;
|
||||||
|
END$$
|
||||||
|
CALL retire_procurement_legacy_tables()$$
|
||||||
|
DROP PROCEDURE retire_procurement_legacy_tables$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- 若上面任一步失败必须停止。剩余旧表数应为 0;此 SELECT 不代替客户端退出码核对。
|
||||||
|
SELECT COUNT(*) AS remaining_legacy_procurement_tables
|
||||||
|
FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||||
|
'll_overseas_procurement_fee',
|
||||||
|
'll_procurement_wallet_attempt',
|
||||||
|
'll_procurement_flow',
|
||||||
|
'll_procurement_command',
|
||||||
|
'll_procurement_budget_authorization',
|
||||||
|
'll_procurement_receipt',
|
||||||
|
'll_procurement_disposition',
|
||||||
|
'll_procurement_manifest',
|
||||||
|
'll_procurement_carton',
|
||||||
|
'll_procurement_carton_source',
|
||||||
|
'll_procurement_shipment',
|
||||||
|
'll_procurement_shipment_item',
|
||||||
|
'll_procurement_label_set',
|
||||||
|
'll_procurement_label_item',
|
||||||
|
'll_procurement_estimate_review',
|
||||||
|
'll_procurement_repack',
|
||||||
|
'll_procurement_fee_correction',
|
||||||
|
'll_procurement_fee_correction_item',
|
||||||
|
'll_procurement_control_note',
|
||||||
|
'll_procurement_closure',
|
||||||
|
'll_procurement_closure_item');
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- 海外采购退役表只读核查;2026-10-27 分离版本。MySQL 8.0+,仅查询,不自动执行删除。
|
||||||
|
-- 6 张当前业务表保留;21 张旧采购专属表已从生产代码和初始化基线移除。
|
||||||
|
-- 只有先部署分离版本并通过 legacy-tables-retire.sql 前置校验后,才可执行精确删表清单。
|
||||||
|
-- 元数据行数是估算;actual_count_sql 供逐表只读核对,不自动执行。
|
||||||
|
WITH inventory AS (
|
||||||
|
SELECT 'll_overseas_procurement_order' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_overseas_procurement_status_history' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_overseas_procurement_file' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_workflow_receipt' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_workflow_outbound' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_workflow_command' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_overseas_procurement_fee' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_wallet_attempt' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_flow' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_command' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_budget_authorization' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_receipt' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_disposition' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_manifest' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_carton' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_carton_source' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_shipment' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_shipment_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_label_set' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_label_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_estimate_review' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_repack' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_fee_correction' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_fee_correction_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_control_note' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_closure' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_closure_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
)
|
||||||
|
SELECT i.table_name,i.disposition,CASE WHEN t.table_name IS NULL THEN 'MISSING' ELSE 'EXISTS' END AS database_state,
|
||||||
|
t.table_rows AS table_rows_estimate,
|
||||||
|
CASE WHEN t.table_name IS NOT NULL THEN CONCAT('SELECT COUNT(*) AS actual_rows FROM baiyue_lastleg.',i.table_name,';') END AS actual_count_sql
|
||||||
|
FROM inventory i LEFT JOIN information_schema.tables t ON t.table_schema='baiyue_lastleg' AND t.table_name=i.table_name
|
||||||
|
ORDER BY i.disposition,i.table_name;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- 仅用于用户确认可丢弃数据的海外采购测试环境,MySQL 8.0+。
|
||||||
|
-- 清空六张当前采购表的数据,删除二十一张旧采购表及其数据。
|
||||||
|
-- 仅作用于 baiyue_lastleg 的采购专属表;不修改共用费用、余额、文件对象及库存。
|
||||||
|
-- 共用表中已有的采购关联记录不会自动删除或退款;本脚本不用于财务结算。
|
||||||
|
-- 先更新分离后的后端并停止采购写入,整份执行,遇错停止,不使用 --force。
|
||||||
|
-- 无存储过程、无 DELIMITER、无需设置会话确认变量;DDL 隐式提交。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
START TRANSACTION;
|
||||||
|
DELETE FROM ll_procurement_workflow_command;
|
||||||
|
DELETE FROM ll_procurement_workflow_outbound;
|
||||||
|
DELETE FROM ll_procurement_workflow_receipt;
|
||||||
|
DELETE FROM ll_overseas_procurement_file;
|
||||||
|
DELETE FROM ll_overseas_procurement_status_history;
|
||||||
|
DELETE FROM ll_overseas_procurement_order;
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS
|
||||||
|
ll_procurement_closure_item,
|
||||||
|
ll_procurement_closure,
|
||||||
|
ll_procurement_control_note,
|
||||||
|
ll_procurement_fee_correction_item,
|
||||||
|
ll_procurement_fee_correction,
|
||||||
|
ll_procurement_repack,
|
||||||
|
ll_procurement_estimate_review,
|
||||||
|
ll_procurement_label_item,
|
||||||
|
ll_procurement_label_set,
|
||||||
|
ll_procurement_shipment_item,
|
||||||
|
ll_procurement_shipment,
|
||||||
|
ll_procurement_carton_source,
|
||||||
|
ll_procurement_carton,
|
||||||
|
ll_procurement_manifest,
|
||||||
|
ll_procurement_disposition,
|
||||||
|
ll_procurement_receipt,
|
||||||
|
ll_procurement_budget_authorization,
|
||||||
|
ll_procurement_command,
|
||||||
|
ll_procurement_flow,
|
||||||
|
ll_procurement_wallet_attempt,
|
||||||
|
ll_overseas_procurement_fee;
|
||||||
|
|
||||||
|
SELECT 'procurement_test_reset_complete' AS result,
|
||||||
|
(SELECT COUNT(*) FROM ll_overseas_procurement_order) AS procurement_orders,
|
||||||
|
(SELECT COUNT(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||||
|
'll_overseas_procurement_fee',
|
||||||
|
'll_procurement_wallet_attempt',
|
||||||
|
'll_procurement_flow',
|
||||||
|
'll_procurement_command',
|
||||||
|
'll_procurement_budget_authorization',
|
||||||
|
'll_procurement_receipt',
|
||||||
|
'll_procurement_disposition',
|
||||||
|
'll_procurement_manifest',
|
||||||
|
'll_procurement_carton',
|
||||||
|
'll_procurement_carton_source',
|
||||||
|
'll_procurement_shipment',
|
||||||
|
'll_procurement_shipment_item',
|
||||||
|
'll_procurement_label_set',
|
||||||
|
'll_procurement_label_item',
|
||||||
|
'll_procurement_estimate_review',
|
||||||
|
'll_procurement_repack',
|
||||||
|
'll_procurement_fee_correction',
|
||||||
|
'll_procurement_fee_correction_item',
|
||||||
|
'll_procurement_control_note',
|
||||||
|
'll_procurement_closure',
|
||||||
|
'll_procurement_closure_item'
|
||||||
|
)) AS remaining_legacy_procurement_tables;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- 简化采购独立批次表;只增加结构,不回填旧采购记录,执行需另行授权。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
-- 简化采购:独立采购库存,收货、占用和签收扣减均由订单锁串行控制。
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
quantity BIGINT NOT NULL COMMENT '本批次数量',
|
||||||
|
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
|
||||||
|
received_at DATETIME(3) NOT NULL COMMENT '本批次实收时间',
|
||||||
|
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
KEY idx_procurement_workflow_receipt (order_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批收货照片与实收数量';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
outbound_no VARCHAR(64) NOT NULL COMMENT '采购出库单号',
|
||||||
|
quantity BIGINT NOT NULL COMMENT '本批次数量',
|
||||||
|
tracking_no VARCHAR(255) NOT NULL COMMENT '物流跟踪号',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '批次运输派送及签收状态',
|
||||||
|
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '记录创建时间',
|
||||||
|
completed_at DATETIME(3) NULL COMMENT '本批次签收完成时间',
|
||||||
|
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
UNIQUE KEY uk_procurement_workflow_outbound_no (outbound_no),
|
||||||
|
KEY idx_procurement_workflow_outbound (order_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批出库物流与签收凭证';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
command_key VARCHAR(128) NOT NULL COMMENT '本单操作幂等键',
|
||||||
|
request_hash VARCHAR(64) NOT NULL COMMENT '动作及请求参数哈希',
|
||||||
|
operator_id VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
|
||||||
|
PRIMARY KEY (order_id,command_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- 固定目标经单独授权的 TEST 或本机开发 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。
|
||||||
|
-- 使用独立连接、遇错停止;先核对既有表结构,不能重放空库初始化。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||||
|
AND COLUMN_NAME IN ('amount','original_amount','exchange_rate','created_by','fee_item_code','direction');
|
||||||
|
|
||||||
|
-- 上次 CALL 前置检查失败可能遗留本专用过程,仅清理本脚本自己的过程后重建。
|
||||||
|
DROP PROCEDURE IF EXISTS storage_rent_zero_fee_repair_20261023;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE storage_rent_zero_fee_repair_20261023()
|
||||||
|
BEGIN
|
||||||
|
DECLARE checked_columns INT DEFAULT 0;
|
||||||
|
DECLARE checked_constraints INT DEFAULT 0;
|
||||||
|
DECLARE rate_type VARCHAR(64);
|
||||||
|
DECLARE rate_comment TEXT;
|
||||||
|
DECLARE rate_default TEXT;
|
||||||
|
DECLARE rate_default_sql TEXT;
|
||||||
|
IF DATABASE()<>'baiyue_lastleg' THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg';
|
||||||
|
END IF;
|
||||||
|
SELECT COUNT(*) INTO checked_columns FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||||
|
AND ((COLUMN_NAME IN ('amount','original_amount') AND DATA_TYPE='decimal' AND IS_NULLABLE='NO')
|
||||||
|
OR (COLUMN_NAME='exchange_rate' AND DATA_TYPE='decimal' AND EXTRA='')
|
||||||
|
OR (COLUMN_NAME IN ('created_by','fee_item_code','direction') AND DATA_TYPE='varchar' AND IS_NULLABLE='NO'));
|
||||||
|
SELECT COUNT(*) INTO checked_constraints FROM information_schema.TABLE_CONSTRAINTS
|
||||||
|
WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||||
|
AND CONSTRAINT_NAME='chk_ll_finance_fee_amount' AND CONSTRAINT_TYPE='CHECK';
|
||||||
|
IF checked_columns<>6 OR checked_constraints<>1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用源表字段或金额约束异常,停止修复';
|
||||||
|
END IF;
|
||||||
|
-- 增加新约束前只读检查;人工费用仍须正额且有效汇率,防止意外放宽。
|
||||||
|
IF EXISTS (SELECT 1 FROM ll_finance_fee_source
|
||||||
|
WHERE NOT ((amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE'))
|
||||||
|
AND ((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')))) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在不满足新约束的费用,停止且不改数据';
|
||||||
|
END IF;
|
||||||
|
SELECT COUNT(*) INTO checked_constraints FROM information_schema.TABLE_CONSTRAINTS
|
||||||
|
WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||||
|
AND CONSTRAINT_NAME='chk_ll_finance_fee_exchange_rate' AND CONSTRAINT_TYPE='CHECK';
|
||||||
|
IF checked_constraints=0 THEN
|
||||||
|
-- 保留真实已有列的精度和备注,不把存量 DECIMAL(18,8) 收窄到基线精度。
|
||||||
|
SELECT COLUMN_TYPE,COLUMN_COMMENT,COLUMN_DEFAULT INTO rate_type,rate_comment,rate_default
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='exchange_rate';
|
||||||
|
IF rate_type NOT REGEXP '^decimal[(][0-9]+,[0-9]+[)]$' THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='汇率列不是预期定点数类型,停止修复';
|
||||||
|
END IF;
|
||||||
|
IF LOCATE('自动零元仓租可空',rate_comment)=0 THEN
|
||||||
|
SET rate_comment=CONCAT(rate_comment,',自动零元仓租可空');
|
||||||
|
END IF;
|
||||||
|
-- 保留既有数值默认值(例如1.00000000);拒绝表达式或非数值默认值。
|
||||||
|
IF rate_default IS NOT NULL AND rate_default NOT REGEXP '^-?[0-9]+([.][0-9]+)?$' THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='汇率默认值不是预期数值,停止修复';
|
||||||
|
END IF;
|
||||||
|
SET rate_default_sql=IF(rate_default IS NULL,' DEFAULT NULL',CONCAT(' DEFAULT ',rate_default));
|
||||||
|
-- 单次原子 ALTER 同步调整可空性与两项约束,原币及结算列完全不改。
|
||||||
|
SET @storage_rent_zero_fee_ddl=CONCAT(
|
||||||
|
'ALTER TABLE baiyue_lastleg.ll_finance_fee_source DROP CHECK chk_ll_finance_fee_amount, ',
|
||||||
|
'MODIFY COLUMN exchange_rate ',rate_type,' NULL',rate_default_sql,' COMMENT ',QUOTE(rate_comment),', ',
|
||||||
|
'ADD CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by=''STORAGE_RENT'' AND fee_item_code=''FEE_TYPE_077'' AND direction=''RECEIVABLE'')), ',
|
||||||
|
'ADD CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by=''STORAGE_RENT'' AND fee_item_code=''FEE_TYPE_077'' AND direction=''RECEIVABLE''))');
|
||||||
|
PREPARE storage_rent_zero_fee_stmt FROM @storage_rent_zero_fee_ddl;
|
||||||
|
EXECUTE storage_rent_zero_fee_stmt;
|
||||||
|
DEALLOCATE PREPARE storage_rent_zero_fee_stmt;
|
||||||
|
SET @storage_rent_zero_fee_ddl=NULL;
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL storage_rent_zero_fee_repair_20261023();
|
||||||
|
DROP PROCEDURE storage_rent_zero_fee_repair_20261023;
|
||||||
|
SHOW CREATE TABLE ll_finance_fee_source;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- 供应商查询缺失结构修复。MySQL 8;仅用于本次明确授权的本机开发库。
|
||||||
|
-- 固定目标 baiyue_warehouse;不修改供应商、费用、余额或授信数据。
|
||||||
|
-- 已有结构安全跳过;不推断历史拖车供应商的国内/海外类别。
|
||||||
|
-- DDL 会隐式提交,请在独立连接以遇错停止模式整份执行。
|
||||||
|
USE baiyue_warehouse;
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SELECT DATABASE() AS target_database;
|
||||||
|
|
||||||
|
-- 先确认正式供应商主表存在;不存在时 ALTER 会报错并停止,不创建第二份主档。
|
||||||
|
SET @supplier_query_column_ddl = IF(
|
||||||
|
EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'baiyue_warehouse'
|
||||||
|
AND table_name = 'wh_supplier' AND column_name = 'trucking_scope'),
|
||||||
|
'SELECT ''trucking_scope 已存在,跳过'' AS structure_result',
|
||||||
|
'ALTER TABLE baiyue_warehouse.wh_supplier ADD COLUMN trucking_scope VARCHAR(16) NULL COMMENT ''拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空'' AFTER category'
|
||||||
|
);
|
||||||
|
PREPARE supplier_query_statement FROM @supplier_query_column_ddl;
|
||||||
|
EXECUTE supplier_query_statement;
|
||||||
|
DEALLOCATE PREPARE supplier_query_statement;
|
||||||
|
|
||||||
|
-- 正式列表还会读取船司关联;若未初始化,仅按当前模块基线建立空表。
|
||||||
|
CREATE TABLE IF NOT EXISTS wh_supplier_carrier (
|
||||||
|
supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'BOOKING供应商ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
|
||||||
|
carrier_code VARCHAR(16) NOT NULL COMMENT '船司标准编码',
|
||||||
|
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (supplier_id,carrier_code),
|
||||||
|
KEY idx_wh_supplier_carrier_code (tenant_id,carrier_code,supplier_id),
|
||||||
|
CONSTRAINT chk_wh_supplier_carrier_tenant CHECK (tenant_id = 0),
|
||||||
|
CONSTRAINT chk_wh_supplier_carrier_code CHECK (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BOOKING供应商与标准船司编码多对多关联';
|
||||||
|
|
||||||
|
-- 执行后只核对结构,不读取供应商联系信息。
|
||||||
|
SELECT table_name, column_name, column_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'baiyue_warehouse'
|
||||||
|
AND ((table_name = 'wh_supplier' AND column_name = 'trucking_scope')
|
||||||
|
OR table_name = 'wh_supplier_carrier')
|
||||||
|
ORDER BY table_name, ordinal_position;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
-- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
|
-- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
|
||||||
|
-- 海外费用菜单及平台管理员授权统一在本文件 IAM 种子中维护,不再单列本次版本或专项菜单脚本。
|
||||||
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
|
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
|
||||||
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
|
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
|
||||||
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
|
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
|
||||||
@@ -3852,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
|
|||||||
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
|
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
|
||||||
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
|
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
|
||||||
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
||||||
|
operator_id bigint unsigned DEFAULT NULL COMMENT '节点操作人账号ID;自动采集时为空',
|
||||||
|
operator_name varchar(128) DEFAULT NULL COMMENT '节点操作人用户名快照;自动采集时为空',
|
||||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
||||||
@@ -5043,6 +5046,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',
|
||||||
@@ -5050,7 +5054,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),
|
||||||
@@ -8458,4 +8462,14 @@ CREATE TABLE wh_document_sequence (
|
|||||||
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
|
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||||
|
|
||||||
|
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
|
||||||
|
USE baiyue_iam;
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
|
||||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
|
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
-- 现场扫描清单独立于客户选箱快照;重复执行不改现有业务数据。
|
||||||
|
SET @discard_actual_marks_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_carton_marks_json'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_carton_marks_json JSON NULL COMMENT ''PDA现场实际废弃箱唛清单,与客户选箱快照独立保存''');
|
||||||
|
PREPARE discard_actual_marks_stmt FROM @discard_actual_marks_ddl;
|
||||||
|
EXECUTE discard_actual_marks_stmt;
|
||||||
|
DEALLOCATE PREPARE discard_actual_marks_stmt;
|
||||||
|
|||||||
+197
-264
@@ -1,4 +1,5 @@
|
|||||||
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
|
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
|
||||||
|
-- 海外费用资金、仓租四表、审核人名称快照及简化采购六表均在本文件最终建表定义中维护。
|
||||||
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
|
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
|
||||||
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
|
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
|
||||||
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
|
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
|
||||||
@@ -314,7 +315,7 @@ CREATE TABLE ll_destuff_pallet_cost_allocation (
|
|||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
|
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
|
||||||
actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收体积',
|
actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收体积',
|
||||||
allocated_amount decimal(20,2) NOT NULL COMMENT '客户分摊金额',
|
allocated_amount decimal(20,2) NOT NULL COMMENT '客户分摊金额',
|
||||||
fee_source_id bigint NOT NULL COMMENT '海外费用来源ID',
|
fee_source_id bigint DEFAULT NULL COMMENT '人工审核后关联的海外费用来源ID',
|
||||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_destuff_customer (cost_id,customer_id),
|
UNIQUE KEY uk_ll_destuff_customer (cost_id,customer_id),
|
||||||
@@ -916,7 +917,7 @@ CREATE TABLE ll_return_carton_stock_receipt (
|
|||||||
CREATE TABLE ll_finance_fee_command (
|
CREATE TABLE ll_finance_fee_command (
|
||||||
id bigint NOT NULL COMMENT '幂等命令ID',
|
id bigint NOT NULL COMMENT '幂等命令ID',
|
||||||
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
|
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
|
||||||
action varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID',
|
action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试',
|
||||||
idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键',
|
idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键',
|
||||||
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256',
|
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256',
|
||||||
source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID',
|
source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID',
|
||||||
@@ -947,6 +948,7 @@ CREATE TABLE ll_finance_fee_source (
|
|||||||
warehouse_id bigint DEFAULT NULL COMMENT '关联海外仓ID',
|
warehouse_id bigint DEFAULT NULL COMMENT '关联海外仓ID',
|
||||||
fee_item_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照',
|
fee_item_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照',
|
||||||
fee_item_name varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照',
|
fee_item_name varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照',
|
||||||
|
fee_remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用说明,补收或重复作业原因',
|
||||||
direction varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
|
direction varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
|
||||||
supplier_id bigint DEFAULT NULL COMMENT '应付供应商ID',
|
supplier_id bigint DEFAULT NULL COMMENT '应付供应商ID',
|
||||||
supplier_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照',
|
supplier_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照',
|
||||||
@@ -962,7 +964,7 @@ CREATE TABLE ll_finance_fee_source (
|
|||||||
amount decimal(20,6) NOT NULL COMMENT '人民币结算金额',
|
amount decimal(20,6) NOT NULL COMMENT '人民币结算金额',
|
||||||
original_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务原币种,支持CNY或USD',
|
original_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务原币种,支持CNY或USD',
|
||||||
original_amount decimal(20,6) NOT NULL COMMENT '业务原币金额',
|
original_amount decimal(20,6) NOT NULL COMMENT '业务原币金额',
|
||||||
exchange_rate decimal(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1',
|
exchange_rate decimal(20,6) DEFAULT NULL COMMENT '原币折算人民币汇率,人民币固定为1,自动零元仓租可空',
|
||||||
exchange_rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份yyyy-MM',
|
exchange_rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份yyyy-MM',
|
||||||
chargeable_quantity decimal(20,6) DEFAULT NULL COMMENT '计费数量,如箱数、托数、体积或天数',
|
chargeable_quantity decimal(20,6) DEFAULT NULL COMMENT '计费数量,如箱数、托数、体积或天数',
|
||||||
quantity_unit varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费数量单位',
|
quantity_unit varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费数量单位',
|
||||||
@@ -974,6 +976,7 @@ CREATE TABLE ll_finance_fee_source (
|
|||||||
source_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
|
source_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
|
||||||
review_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因',
|
review_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因',
|
||||||
reviewer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
reviewer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
||||||
|
reviewer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人名称快照',
|
||||||
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
|
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
|
||||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID',
|
created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID',
|
||||||
updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID',
|
updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID',
|
||||||
@@ -984,7 +987,8 @@ CREATE TABLE ll_finance_fee_source (
|
|||||||
KEY idx_ll_finance_fee_customer (customer_id,source_status,created_at),
|
KEY idx_ll_finance_fee_customer (customer_id,source_status,created_at),
|
||||||
KEY idx_ll_finance_fee_business (business_type,business_no,created_at),
|
KEY idx_ll_finance_fee_business (business_type,business_no,created_at),
|
||||||
KEY idx_ll_finance_fee_container_customer (container_booking_id,customer_id,source_status,created_at),
|
KEY idx_ll_finance_fee_container_customer (container_booking_id,customer_id,source_status,created_at),
|
||||||
CONSTRAINT chk_ll_finance_fee_amount CHECK ((amount > 0)),
|
CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')),
|
||||||
|
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')),
|
||||||
CONSTRAINT chk_ll_finance_fee_currency CHECK ((currency = _utf8mb4'CNY')),
|
CONSTRAINT chk_ll_finance_fee_currency CHECK ((currency = _utf8mb4'CNY')),
|
||||||
CONSTRAINT chk_ll_finance_fee_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))),
|
CONSTRAINT chk_ll_finance_fee_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))),
|
||||||
CONSTRAINT chk_ll_finance_fee_original_currency CHECK ((original_currency in (_utf8mb4'CNY',_utf8mb4'USD'))),
|
CONSTRAINT chk_ll_finance_fee_original_currency CHECK ((original_currency in (_utf8mb4'CNY',_utf8mb4'USD'))),
|
||||||
@@ -2056,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',
|
||||||
@@ -2122,6 +2079,7 @@ CREATE TABLE ll_overseas_procurement_file (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证';
|
||||||
|
|
||||||
CREATE TABLE ll_overseas_procurement_order (
|
CREATE TABLE ll_overseas_procurement_order (
|
||||||
|
procurement_direction VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS_TO_DOMESTIC' COMMENT '采购方向:海外采购回国内/国内采购发海外',
|
||||||
id bigint NOT NULL COMMENT '主键ID',
|
id bigint NOT NULL COMMENT '主键ID',
|
||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||||
customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照',
|
customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照',
|
||||||
@@ -2147,6 +2105,8 @@ CREATE TABLE ll_overseas_procurement_order (
|
|||||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
||||||
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
||||||
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
|
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
|
||||||
|
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
|
||||||
|
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
|
||||||
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
|
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
|
||||||
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
|
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
|
||||||
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
|
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
|
||||||
@@ -3972,245 +3932,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;
|
||||||
|
|
||||||
@@ -4593,6 +4341,7 @@ CREATE TABLE ll_return_sku_conversion (
|
|||||||
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
|
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
|
||||||
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
|
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
|
||||||
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
|
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
|
||||||
|
storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照',
|
||||||
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
|
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
|
||||||
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
|
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
|
||||||
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
|
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
|
||||||
@@ -4987,4 +4736,188 @@ CREATE TABLE ll_document_sequence (
|
|||||||
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
|
-- 海外费用资金及实收仓租。
|
||||||
|
-- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。
|
||||||
|
CREATE TABLE ll_finance_fee_funding (
|
||||||
|
source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '费用所属客户隔离标识',
|
||||||
|
collection_mode VARCHAR(16) NOT NULL COMMENT '客户收费方式:BILL账单结算,WALLET余额及授信',
|
||||||
|
wallet_status VARCHAR(32) NOT NULL COMMENT '资金进度:待冻结、已冻结、待扣款、已扣款、待释放及失败状态',
|
||||||
|
wallet_business_no VARCHAR(128) NULL COMMENT '本次冻结使用的独立业务编号,重提使用新编号',
|
||||||
|
command_event_id VARCHAR(64) NULL COMMENT '当前待处理资金指令事件ID',
|
||||||
|
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance确认的资金冻结记录ID',
|
||||||
|
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance确认的已扣资金流水ID',
|
||||||
|
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调递增版本,防止重试重复入账',
|
||||||
|
error_message VARCHAR(500) NULL COMMENT '最新资金失败原因,成功后清空',
|
||||||
|
command_history JSON NOT NULL COMMENT '已发资金指令及核验结果哈希,识别重复和错序回执',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '资金编排创建UTC时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '资金编排最近变更UTC时间',
|
||||||
|
PRIMARY KEY (source_fee_id),
|
||||||
|
KEY idx_ll_fee_funding_customer_state (customer_id, wallet_status),
|
||||||
|
KEY idx_ll_fee_funding_command (command_event_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外人工费用资金冻结、审核扣款及释放编排';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS 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='头程实收计租幂等登记';
|
||||||
|
|
||||||
|
CREATE TABLE ll_storage_rent_lot (
|
||||||
|
id bigint NOT NULL COMMENT '原批次主键',
|
||||||
|
source_key varchar(80) NOT NULL COMMENT '实收来源幂等键',
|
||||||
|
customer_id varchar(32) NOT NULL COMMENT '客户标识',
|
||||||
|
customer_name varchar(255) DEFAULT NULL COMMENT '客户名称',
|
||||||
|
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
|
||||||
|
warehouse_name varchar(255) DEFAULT NULL COMMENT '海外仓名称',
|
||||||
|
business_type varchar(32) NOT NULL COMMENT '原入库业务类型',
|
||||||
|
business_id bigint NOT NULL COMMENT '原入库业务主键',
|
||||||
|
business_no varchar(128) NOT NULL COMMENT '原入库业务单号',
|
||||||
|
order_id bigint DEFAULT NULL COMMENT '尾程订单主键',
|
||||||
|
inbound_code varchar(128) DEFAULT NULL COMMENT '入仓码',
|
||||||
|
balance_id bigint DEFAULT NULL COMMENT '普通库存余额主键',
|
||||||
|
batch_no varchar(128) DEFAULT NULL COMMENT '原实收批次',
|
||||||
|
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
|
||||||
|
rule_code varchar(32) NOT NULL COMMENT '仓租规则',
|
||||||
|
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
|
||||||
|
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
|
||||||
|
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
|
||||||
|
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
|
||||||
|
created_at datetime(3) NOT NULL COMMENT '创建时间',
|
||||||
|
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_storage_rent_lot_source (source_key),
|
||||||
|
KEY idx_storage_rent_stock (balance_id,batch_no),
|
||||||
|
KEY idx_storage_rent_customer (customer_id,warehouse_id,inbound_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实收计租原批次';
|
||||||
|
|
||||||
|
CREATE TABLE ll_storage_rent_share (
|
||||||
|
balance_id bigint DEFAULT NULL COMMENT '普通库存当前余额,退货箱份额为空',
|
||||||
|
batch_no varchar(128) DEFAULT NULL COMMENT '普通库存当前批次,换标搬移保持原计租批次引用',
|
||||||
|
id bigint NOT NULL COMMENT '份额主键',
|
||||||
|
lot_id bigint NOT NULL COMMENT '原计租批次主键',
|
||||||
|
customer_id varchar(32) NOT NULL COMMENT '客户标识',
|
||||||
|
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
|
||||||
|
carton_mark varchar(255) DEFAULT NULL COMMENT '当前箱唛',
|
||||||
|
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
|
||||||
|
quantity decimal(24,12) NOT NULL COMMENT '剩余商品数量',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_storage_rent_return_share (lot_id,carton_mark,sku_code),
|
||||||
|
UNIQUE KEY uk_storage_rent_stock_share (lot_id,balance_id,batch_no),
|
||||||
|
KEY idx_storage_rent_carton (customer_id,warehouse_id,carton_mark),
|
||||||
|
KEY idx_storage_rent_share_stock (customer_id,warehouse_id,balance_id,batch_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前库存与原计租批次份额';
|
||||||
|
|
||||||
|
CREATE TABLE ll_storage_rent_preparation (
|
||||||
|
id bigint NOT NULL COMMENT '预校验主键',
|
||||||
|
warehouse_id varchar(32) NOT NULL COMMENT '作业海外仓',
|
||||||
|
customer_id varchar(32) NOT NULL COMMENT '费用客户',
|
||||||
|
mode varchar(32) NOT NULL COMMENT '扫码类型',
|
||||||
|
execution_no varchar(128) NOT NULL COMMENT '实际作业单号',
|
||||||
|
request_key varchar(255) NOT NULL COMMENT '原扫码幂等键',
|
||||||
|
request_hash varchar(64) NOT NULL COMMENT '原扫码内容哈希',
|
||||||
|
attempt int NOT NULL COMMENT '冻结尝试次数',
|
||||||
|
business_type varchar(32) NOT NULL COMMENT '本次应收业务类型',
|
||||||
|
business_id bigint NOT NULL COMMENT '本次应收业务主键',
|
||||||
|
business_no varchar(128) NOT NULL COMMENT '本次应收业务单号',
|
||||||
|
status varchar(32) NOT NULL COMMENT '资金与扫码状态',
|
||||||
|
plan_json json NOT NULL COMMENT '原批次计费不可变快照',
|
||||||
|
original_amount decimal(24,12) NOT NULL COMMENT '原币仓租金额,币种见计费快照',
|
||||||
|
amount decimal(24,12) NOT NULL COMMENT '人民币结算金额',
|
||||||
|
exchange_rate decimal(24,12) NULL COMMENT '冻结确认汇率,免费出库为空',
|
||||||
|
exchange_rate_month varchar(7) NOT NULL COMMENT '汇率月份',
|
||||||
|
freeze_event_id varchar(128) DEFAULT NULL COMMENT '冻结指令事件',
|
||||||
|
settle_event_id varchar(128) DEFAULT NULL COMMENT '扣款指令事件',
|
||||||
|
release_event_id varchar(128) DEFAULT NULL COMMENT '解冻指令事件',
|
||||||
|
release_status varchar(32) DEFAULT NULL COMMENT '解冻回执状态,失败后由恢复任务重发',
|
||||||
|
wallet_version bigint NOT NULL COMMENT '同一预占资金命令版本,重试使用新版本保留审计',
|
||||||
|
hold_id varchar(128) DEFAULT NULL COMMENT '财务冻结主键',
|
||||||
|
ledger_id varchar(128) DEFAULT NULL COMMENT '财务扣款流水',
|
||||||
|
error_message varchar(1000) DEFAULT NULL COMMENT '资金处理结果',
|
||||||
|
source_fee_id bigint DEFAULT NULL COMMENT '财务费用来源主键',
|
||||||
|
created_at datetime(3) NOT NULL COMMENT '预校验时间',
|
||||||
|
consumed_at datetime(3) DEFAULT NULL COMMENT '实际扫描出库时间',
|
||||||
|
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_storage_rent_request (warehouse_id,mode,request_key,attempt),
|
||||||
|
KEY idx_storage_rent_execution (mode,execution_no,status),
|
||||||
|
KEY idx_storage_rent_business (customer_id,business_no),
|
||||||
|
KEY idx_storage_rent_recovery (status,updated_at),
|
||||||
|
KEY idx_storage_rent_release (release_status,updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fleet_resource_photo (
|
||||||
|
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||||
|
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||||
|
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||||
|
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||||
|
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||||
|
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||||
|
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||||
|
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||||
|
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||||
|
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||||
|
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
-- 简化采购:独立采购库存,收货、占用和签收扣减均由订单锁串行控制。
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
quantity BIGINT NOT NULL COMMENT '本批次数量',
|
||||||
|
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
|
||||||
|
received_at DATETIME(3) NOT NULL COMMENT '本批次实收时间',
|
||||||
|
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
KEY idx_procurement_workflow_receipt (order_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批收货照片与实收数量';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
outbound_no VARCHAR(64) NOT NULL COMMENT '采购出库单号',
|
||||||
|
quantity BIGINT NOT NULL COMMENT '本批次数量',
|
||||||
|
tracking_no VARCHAR(255) NOT NULL COMMENT '物流跟踪号',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '批次运输派送及签收状态',
|
||||||
|
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '记录创建时间',
|
||||||
|
completed_at DATETIME(3) NULL COMMENT '本批次签收完成时间',
|
||||||
|
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
UNIQUE KEY uk_procurement_workflow_outbound_no (outbound_no),
|
||||||
|
KEY idx_procurement_workflow_outbound (order_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批出库物流与签收凭证';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
command_key VARCHAR(128) NOT NULL COMMENT '本单操作幂等键',
|
||||||
|
request_hash VARCHAR(64) NOT NULL COMMENT '动作及请求参数哈希',
|
||||||
|
operator_id VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
|
||||||
|
PRIMARY KEY (order_id,command_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
|
||||||
|
|
||||||
|
CREATE TABLE ll_order_budget_reservation (
|
||||||
|
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
|
||||||
|
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
|
||||||
|
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
|
||||||
|
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
|
||||||
|
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
|
||||||
|
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
|
||||||
|
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
|
||||||
|
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
|
||||||
|
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
|
||||||
|
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
|
||||||
|
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
|
||||||
|
KEY idx_order_budget_release(status,release_requested,created_at),
|
||||||
|
KEY idx_order_budget_order(customer_id,kind,order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
|
||||||
|
|
||||||
|
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 223 AS initialized_tables;
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import java.util.Arrays;
|
|||||||
|
|
||||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||||
enum BusinessFileCategory {
|
enum BusinessFileCategory {
|
||||||
|
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_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),
|
||||||
@@ -90,7 +92,7 @@ enum BusinessFileCategory {
|
|||||||
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
|
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
|
||||||
}
|
}
|
||||||
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
|
if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
||||||
}
|
}
|
||||||
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ info:
|
|||||||
复用InternalBusinessFileController,不增加公网File路径。
|
复用InternalBusinessFileController,不增加公网File路径。
|
||||||
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
|
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
|
||||||
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
|
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
|
||||||
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
|
装车资料接受非空JPG/PNG/PDF(Lastleg仅允许司机驾驶证类别使用PDF),油耗仍仅接受JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
|
||||||
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
|
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
|
||||||
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
|
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
|
||||||
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
|
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: 车辆与司机档案照片内部文件契约
|
||||||
|
version: 1.0.0
|
||||||
|
description: >-
|
||||||
|
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_VEHICLE_PHOTO或FLEET_DRIVER_PHOTO、
|
||||||
|
businessId=对应车辆或司机内部ID。Lastleg负责档案存在性、列表/维护权限、版本关联和历史可见性;
|
||||||
|
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅JPG/PNG。
|
||||||
|
上传只生成不可变对象,不改变驾驶证审核结论,不生成公开URL,排车保存时复制为本趟证据,后续档案替换不改历史排车。
|
||||||
|
security:
|
||||||
|
- InternalServiceToken: []
|
||||||
|
paths:
|
||||||
|
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/TenantId'}
|
||||||
|
- {$ref: '#/components/parameters/BusinessType'}
|
||||||
|
- {$ref: '#/components/parameters/BusinessId'}
|
||||||
|
post:
|
||||||
|
summary: 上传车辆或司机档案照片
|
||||||
|
parameters:
|
||||||
|
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file]
|
||||||
|
properties:
|
||||||
|
file: {type: string, format: binary}
|
||||||
|
responses:
|
||||||
|
'200': {$ref: '#/components/responses/Metadata'}
|
||||||
|
'403': {description: 内部服务令牌无效}
|
||||||
|
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/TenantId'}
|
||||||
|
- {$ref: '#/components/parameters/BusinessType'}
|
||||||
|
- {$ref: '#/components/parameters/BusinessId'}
|
||||||
|
- {$ref: '#/components/parameters/FileId'}
|
||||||
|
get:
|
||||||
|
summary: 校验归属并读取元数据
|
||||||
|
responses:
|
||||||
|
'200': {$ref: '#/components/responses/Metadata'}
|
||||||
|
'403': {description: 内部服务令牌无效}
|
||||||
|
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/TenantId'}
|
||||||
|
- {$ref: '#/components/parameters/BusinessType'}
|
||||||
|
- {$ref: '#/components/parameters/BusinessId'}
|
||||||
|
- {$ref: '#/components/parameters/FileId'}
|
||||||
|
get:
|
||||||
|
summary: 校验归属并下载真实文件
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 文件内容,业务错误按JSON code识别
|
||||||
|
content:
|
||||||
|
image/jpeg: {schema: {type: string, format: binary}}
|
||||||
|
image/png: {schema: {type: string, format: binary}}
|
||||||
|
'403': {description: 内部服务令牌无效}
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||||
|
parameters:
|
||||||
|
TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}}
|
||||||
|
BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}}
|
||||||
|
BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
|
FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
|
responses:
|
||||||
|
Metadata:
|
||||||
|
description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code: {type: string}
|
||||||
|
message: {type: string, nullable: true}
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
fileId: {type: string}
|
||||||
|
tenantId: {type: string, enum: ['0']}
|
||||||
|
businessType: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}
|
||||||
|
businessId: {type: string}
|
||||||
|
fileName: {type: string}
|
||||||
|
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||||
|
contentType: {type: string, enum: [image/jpeg, image/png]}
|
||||||
|
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
|
||||||
@@ -22,6 +22,12 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
class BusinessFileStorageTest {
|
class BusinessFileStorageTest {
|
||||||
@TempDir Path root;
|
@TempDir Path root;
|
||||||
|
@Test void loadingLicenseStoresPdfButFuelStillRejectsPdf() {
|
||||||
|
var file=new MockMultipartFile("file","license.pdf","application/pdf","%PDF-1.4\n%%EOF".getBytes());
|
||||||
|
var stored=storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_LOADING_PHOTO);
|
||||||
|
assertThat(stored.contentType()).isEqualTo("application/pdf");
|
||||||
|
assertThatThrownBy(()->storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_FUEL_PHOTO)).isInstanceOf(BizException.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
|
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
|
||||||
BusinessFileStorage storage = storage();
|
BusinessFileStorage storage = storage();
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class FleetResourcePhotoCategoryTest {
|
||||||
|
@Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() {
|
||||||
|
for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) {
|
||||||
|
category.validateOwner(0);
|
||||||
|
assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台");
|
||||||
|
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
|
||||||
|
assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024);
|
||||||
|
assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
|
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
|
||||||
| 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
|
| 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
|
||||||
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
|
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
|
||||||
| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放,第三方支付回调尚未接入 |
|
| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放;原消费关联退款契约见 `src/main/resources/openapi/finance-wallet-refund-v1.yaml`,第三方支付回调尚未接入 |
|
||||||
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
|
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
|
||||||
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
|
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
|
||||||
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
|
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
|
||||||
|
|||||||
+26
@@ -7,11 +7,16 @@ import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest;
|
|||||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
|
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
|
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
|
||||||
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest;
|
||||||
import com.baiyue.finance.service.FinanceWalletService;
|
import com.baiyue.finance.service.FinanceWalletService;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/platform/finance/v1")
|
@RequestMapping("/api/platform/finance/v1")
|
||||||
@@ -62,4 +67,25 @@ public class PlatformFinanceWalletController {
|
|||||||
@GetMapping("/recharge-requests/{requestId}/voucher")
|
@GetMapping("/recharge-requests/{requestId}/voucher")
|
||||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||||
public ResponseEntity<byte[]> voucher(@PathVariable long requestId) { return service.voucher(requestId); }
|
public ResponseEntity<byte[]> voucher(@PathVariable long requestId) { return service.voucher(requestId); }
|
||||||
|
|
||||||
|
/** 退款必须由平台财务针对原消费发起,复用资金服务的账户锁及累计可退上限。 */
|
||||||
|
@PostMapping("/accounts/{customerId}/ledger/{ledgerId}/refunds")
|
||||||
|
@PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')")
|
||||||
|
public ApiResponse<?> refund(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable long customerId, @PathVariable long ledgerId,
|
||||||
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||||
|
@RequestBody WalletRefundRequest request) {
|
||||||
|
if (user == null || user.userId() == null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台财务可操作退款");
|
||||||
|
}
|
||||||
|
if (request == null || idempotencyKey == null || idempotencyKey.isBlank()
|
||||||
|
|| idempotencyKey.trim().length() > 128) throw new IllegalArgumentException("退款请求及幂等键必填且幂等键不超过128字符");
|
||||||
|
if (request.reason() == null || request.reason().isBlank() || request.reason().length() > 500)
|
||||||
|
throw new IllegalArgumentException("退款原因必填且不超过500字符");
|
||||||
|
// 技术退款引用由客户和幂等键确定;资金流水号仍由正式 AC 编号生成器分配。
|
||||||
|
String refundNo = "MANUAL_REFUND:" + UUID.nameUUIDFromBytes(
|
||||||
|
(customerId + ":" + idempotencyKey.trim()).getBytes(StandardCharsets.UTF_8));
|
||||||
|
return FinanceResponses.success(service.refundConsumption(customerId, ledgerId, refundNo,
|
||||||
|
idempotencyKey.trim(), request.amount(), user.userId(), user.displayName(), request.reason().trim()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.baiyue.finance.dto;
|
||||||
|
|
||||||
|
/** 接收事务锁定的来源版本及其不可变客户归属。 */
|
||||||
|
public record BusinessFeeSourceVersion(int sourceVersion, long customerId) { }
|
||||||
@@ -54,6 +54,9 @@ public final class FinanceWalletDtos {
|
|||||||
|
|
||||||
public record WalletHoldActionRequest(String remark) { }
|
public record WalletHoldActionRequest(String remark) { }
|
||||||
|
|
||||||
|
/** 关联原消费退款,原因必填,原流水保持不可变。 */
|
||||||
|
public record WalletRefundRequest(BigDecimal amount, String reason) { }
|
||||||
|
|
||||||
public record WalletHold(long holdId, long customerId, String businessType,
|
public record WalletHold(long holdId, long customerId, String businessType,
|
||||||
String businessNo, BigDecimal amount,
|
String businessNo, BigDecimal amount,
|
||||||
BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount,
|
BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount,
|
||||||
|
|||||||
@@ -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<>'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<>'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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<>();
|
||||||
|
|||||||
+36
@@ -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); }
|
||||||
|
}
|
||||||
+69
-13
@@ -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,11 +141,13 @@ public class BusinessFeeSourceEventService {
|
|||||||
}
|
}
|
||||||
positive(value.sourceFeeId(), "sourceFeeId");
|
positive(value.sourceFeeId(), "sourceFeeId");
|
||||||
positive(value.customerId(), "customerId");
|
positive(value.customerId(), "customerId");
|
||||||
|
validateAmounts(value);
|
||||||
if (value.walletSettlement() != null) {
|
if (value.walletSettlement() != null) {
|
||||||
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
|
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
|
||||||
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
|
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
|
||||||
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
|
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
|
||||||
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
|
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
|
||||||
|
walletIdentity(value);
|
||||||
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
|
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
|
||||||
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
|
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
|
||||||
}
|
}
|
||||||
@@ -148,13 +161,11 @@ 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())
|
||||||
|| !blank(value.settlementMode()) || value.paidAt() != null
|
|| value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null
|
||||||
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|
||||||
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
|
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
|
||||||
throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实");
|
throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实");
|
||||||
@@ -173,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())) {
|
||||||
@@ -202,9 +238,10 @@ public class BusinessFeeSourceEventService {
|
|||||||
long customerId = positive(source.customerId(), "customerId");
|
long customerId = positive(source.customerId(), "customerId");
|
||||||
long holdId = positive(reference.holdId(), "holdId");
|
long holdId = positive(reference.holdId(), "holdId");
|
||||||
long ledgerId = positive(reference.ledgerId(), "ledgerId");
|
long ledgerId = positive(reference.ledgerId(), "ledgerId");
|
||||||
|
String[] identity = walletIdentity(source);
|
||||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||||
"SELECT l.amount,c.result_json FROM fin_wallet_hold h JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id JOIN fin_wallet_command_inbox c ON c.event_id=? AND c.status='SUCCEEDED' WHERE h.id=? AND l.id=? AND h.customer_id=? AND h.business_type=? AND h.business_no=? AND h.status='SETTLED' AND l.change_type='CONSUMPTION' FOR UPDATE",
|
"SELECT l.amount,c.result_json FROM fin_wallet_hold h JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id JOIN fin_wallet_command_inbox c ON c.event_id=? AND c.status='SUCCEEDED' WHERE h.id=? AND l.id=? AND h.customer_id=? AND h.business_type=? AND h.business_no=? AND h.status='SETTLED' AND l.change_type='CONSUMPTION' FOR UPDATE",
|
||||||
reference.commandEventId(), holdId, ledgerId, customerId, source.businessType(), source.businessNo());
|
reference.commandEventId(), holdId, ledgerId, customerId, identity[0], identity[1]);
|
||||||
if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount)
|
if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount)
|
||||||
|| amount.negate().compareTo(source.totalAmount()) != 0) {
|
|| amount.negate().compareTo(source.totalAmount()) != 0) {
|
||||||
throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配");
|
throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配");
|
||||||
@@ -218,8 +255,8 @@ public class BusinessFeeSourceEventService {
|
|||||||
|| !reference.holdId().equals(result.path("walletHoldId").asText())
|
|| !reference.holdId().equals(result.path("walletHoldId").asText())
|
||||||
|| !reference.ledgerId().equals(result.path("walletLedgerId").asText())
|
|| !reference.ledgerId().equals(result.path("walletLedgerId").asText())
|
||||||
|| result.path("customerId").asLong() != customerId
|
|| result.path("customerId").asLong() != customerId
|
||||||
|| !source.businessType().equals(result.path("businessType").asText())
|
|| !identity[0].equals(result.path("businessType").asText())
|
||||||
|| !source.businessNo().equals(result.path("businessNo").asText())
|
|| !identity[1].equals(result.path("businessNo").asText())
|
||||||
|| result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) {
|
|| result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) {
|
||||||
throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配");
|
throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配");
|
||||||
}
|
}
|
||||||
@@ -234,6 +271,25 @@ public class BusinessFeeSourceEventService {
|
|||||||
feeSourceId);
|
feeSourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 独立锁款仅接收已约定的人工费用或仓租身份,不能借此挂接任意业务扣款。 */
|
||||||
|
private String[] walletIdentity(BusinessFeeSourceChangedV1 source) {
|
||||||
|
var reference = source.walletSettlement();
|
||||||
|
String type = reference.walletBusinessType(), no = reference.walletBusinessNo();
|
||||||
|
if (type == null && no == null) return new String[]{source.businessType(), source.businessNo()};
|
||||||
|
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
|
||||||
|
throw error("FIN-EVT-017", "资金业务引用不完整");
|
||||||
|
if ("OVERSEAS_MANUAL_FEE".equals(type)) {
|
||||||
|
if ("FEE_TYPE_077".equals(source.feeItemCode())
|
||||||
|
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
|
||||||
|
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
|
||||||
|
} else if ("STORAGE_RENT".equals(type)) {
|
||||||
|
if (!"FEE_TYPE_077".equals(source.feeItemCode()))
|
||||||
|
throw error("FIN-EVT-017", "仓租资金引用与费用项目不匹配");
|
||||||
|
positive(no, "walletBusinessNo");
|
||||||
|
} else throw error("FIN-EVT-017", "不支持的独立资金业务引用");
|
||||||
|
return new String[]{type, no};
|
||||||
|
}
|
||||||
|
|
||||||
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
|
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
|
||||||
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
|
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
|
||||||
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
|
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
|
||||||
|
|||||||
@@ -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+");
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-41
@@ -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(),
|
||||||
|
|||||||
+4
-25
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-12
@@ -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", "对账单修订快照损坏");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: 平台资金账户关联退款
|
||||||
|
version: 1.0.0
|
||||||
|
description: 原消费保持不可变;退款记录关联原消费流水号,累计退款不得超过原消费金额,包含业务退款事件已退金额。
|
||||||
|
paths:
|
||||||
|
/api/platform/finance/v1/accounts/{customerId}/ledger/{ledgerId}/refunds:
|
||||||
|
post:
|
||||||
|
operationId: refundWalletConsumption
|
||||||
|
summary: 原消费关联退款
|
||||||
|
description: 仅平台 tenantId=0 且具有 platform:finance:customer-account:adjust 权限可操作。账户行锁串行校验累计可退金额,无需前端提交账户版本。优先归还已用授信,再增加实际余额。相同键同请求返回原流水;相同键不同原消费或金额失败。非成功消费、跨客户原流水、超额退款均拒绝。
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- {name: customerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
|
- {name: ledgerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
|
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [amount, reason]
|
||||||
|
properties:
|
||||||
|
amount: {type: number, minimum: 0.01, maximum: 999999999999.99, multipleOf: 0.01}
|
||||||
|
reason: {type: string, minLength: 1, maxLength: 500, description: 必填退款原因,不得仅空白}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 统一响应 code=200 成功;业务失败使用 FIN-REQ-400、FIN-WALLET-404、FIN-IDEMPOTENCY-CONFLICT 或 FIN-WALLET-REFUND-EXCEEDED 等业务码。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code: {type: string}
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/RefundLedgerEntry'
|
||||||
|
'403': {description: 非平台主体或缺少调账权限}
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||||
|
schemas:
|
||||||
|
RefundLedgerEntry:
|
||||||
|
type: object
|
||||||
|
description: 完整返回 FinanceWalletDtos.WalletLedgerEntry,资金余额字段沿用账户流水协议。
|
||||||
|
properties:
|
||||||
|
ledgerId: {type: string}
|
||||||
|
businessNo: {type: string, description: 新退款流水正式 AC 单号}
|
||||||
|
changeType: {type: string, enum: [REFUND]}
|
||||||
|
amount: {type: number, description: 实际退款正金额}
|
||||||
|
sourceType: {type: string, enum: [BUSINESS_REFUND]}
|
||||||
|
sourceNo: {type: string, description: 客户及幂等键派生的稳定技术退款引用}
|
||||||
|
referenceNo: {type: string, description: 原消费流水 businessNo,用于关联及累计可退校验}
|
||||||
|
operatorName: {type: string, nullable: true}
|
||||||
|
remark: {type: string, description: 退款原因}
|
||||||
|
actualBalanceAfter: {type: number}
|
||||||
|
frozenBalanceAfter: {type: number}
|
||||||
|
availableBalanceAfter: {type: number}
|
||||||
|
creditUsedAfter: {type: number}
|
||||||
|
availableCreditAfter: {type: number}
|
||||||
@@ -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);
|
||||||
@@ -286,4 +369,82 @@ class BusinessFeeSourceEventServiceTest {
|
|||||||
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
|
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
|
||||||
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void manualConsumptionUsesDedicatedFundingIdentityWhilePreservingServiceOrder() {
|
||||||
|
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
|
||||||
|
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dedicatedFundingCannotReferenceAnotherManualFeeOrWrongFeeItem() {
|
||||||
|
assertThatThrownBy(() -> service.consume(dedicatedEvent("OVERSEAS_MANUAL_FEE", "702-1", "FEE_TYPE_071")))
|
||||||
|
.hasMessageContaining("来源不匹配");
|
||||||
|
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_071")))
|
||||||
|
.hasMessageContaining("项目不匹配");
|
||||||
|
noFinancialWrites();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dedicatedConsumptionCannotReuseHoldAlreadyLinkedToAnotherSource() {
|
||||||
|
stubDedicatedConsumption("STORAGE_RENT", "445");
|
||||||
|
when(jdbc.queryForList(startsWith("SELECT id FROM fin_fee_source WHERE wallet_hold_id="),
|
||||||
|
eq(Long.class), eq(9L), eq(901L))).thenReturn(List.of(902L));
|
||||||
|
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077")))
|
||||||
|
.hasMessageContaining("已关联其他费用");
|
||||||
|
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dedicatedConsumptionRejectsResultFromDifferentFundingBusiness() {
|
||||||
|
stubDedicatedConsumption("STORAGE_RENT", "445");
|
||||||
|
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
|
||||||
|
eq(10001L), eq("STORAGE_RENT"), eq("445"))).thenReturn(List.of(Map.of(
|
||||||
|
"amount", new BigDecimal("-120.00"), "result_json", """
|
||||||
|
{"result":"SUCCEEDED","operation":"SETTLE","commandEventId":"command-1",
|
||||||
|
"walletHoldId":"9","walletLedgerId":"72","customerId":10001,
|
||||||
|
"businessType":"STORAGE_RENT","businessNo":"446","amount":120.00}
|
||||||
|
""")));
|
||||||
|
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077")))
|
||||||
|
.hasMessageContaining("资金结果不匹配");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyDedicatedConsumption(String type, String no, String item) {
|
||||||
|
stubDedicatedConsumption(type, no);
|
||||||
|
EventEnvelope event = dedicatedEvent(type, no, item);
|
||||||
|
service.consume(event);
|
||||||
|
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L));
|
||||||
|
assertThat(event.payload().path("businessType").asText()).isEqualTo("WAREHOUSE_SERVICE");
|
||||||
|
assertThat(event.payload().path("businessNo").asText()).isEqualTo("PO-US-001");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubDedicatedConsumption(String type, String no) {
|
||||||
|
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);
|
||||||
|
ObjectNode result = json.createObjectNode();
|
||||||
|
result.put("result", "SUCCEEDED").put("operation", "SETTLE").put("commandEventId", "command-1")
|
||||||
|
.put("walletHoldId", "9").put("walletLedgerId", "72").put("customerId", 10001)
|
||||||
|
.put("businessType", type).put("businessNo", no).put("amount", new BigDecimal("120.00"));
|
||||||
|
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
|
||||||
|
eq(10001L), eq(type), eq(no))).thenReturn(List.of(Map.of(
|
||||||
|
"amount", new BigDecimal("-120.00"), "result_json", result.toString())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventEnvelope dedicatedEvent(String type, String no, String item) {
|
||||||
|
EventEnvelope base = eventWithoutInbound();
|
||||||
|
ObjectNode payload = base.payload().deepCopy();
|
||||||
|
payload.put("feeItemCode", item);
|
||||||
|
payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9")
|
||||||
|
.put("ledgerId", "72").put("walletBusinessType", type).put("walletBusinessNo", no);
|
||||||
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+238
@@ -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() {
|
||||||
|
|||||||
+3
-3
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.finance.controller.PlatformFinanceWalletController;
|
||||||
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest;
|
||||||
|
import com.baiyue.finance.service.FinanceWalletService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Set;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** 平台退款入口鉴权、必填原因和稳定幂等引用的回归。 */
|
||||||
|
class PlatformFinanceWalletRefundControllerTest {
|
||||||
|
private final FinanceWalletService service = mock(FinanceWalletService.class);
|
||||||
|
private final PlatformFinanceWalletController controller = new PlatformFinanceWalletController(service);
|
||||||
|
private final WalletRefundRequest request = new WalletRefundRequest(new BigDecimal("12.00"), "退回多收费用");
|
||||||
|
|
||||||
|
/** 客户即使携带同名权限也不能借平台入口操作他人资金。 */
|
||||||
|
@Test void nonPlatformPrincipalsAreDeniedBeforeWalletAccess() {
|
||||||
|
for (var user : new AuthenticatedUser[]{null, user("CLIENT", 9L), user("PLATFORM", 9L)})
|
||||||
|
assertThatThrownBy(() -> controller.refund(user, 9, 1, "key", request))
|
||||||
|
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一客户请求键生成同一退款引用,实际防重与金额上限仍由资金事务执行。 */
|
||||||
|
@Test void authorizedCallsUseStableReferenceAndExistingAdjustPermission() throws Exception {
|
||||||
|
controller.refund(user("PLATFORM", 0L), 9, 1, " key ", request);
|
||||||
|
controller.refund(user("PLATFORM", 0L), 9, 1, "key", request);
|
||||||
|
var reference = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(service, times(2)).refundConsumption(eq(9L), eq(1L), reference.capture(), eq("key"),
|
||||||
|
eq(request.amount()), eq(7L), isNull(), eq(request.reason()));
|
||||||
|
assertThat(reference.getAllValues()).allSatisfy(value -> assertThat(value).startsWith("MANUAL_REFUND:"));
|
||||||
|
assertThat(reference.getAllValues().get(0)).isEqualTo(reference.getAllValues().get(1));
|
||||||
|
assertThat(PlatformFinanceWalletController.class.getMethod("refund", AuthenticatedUser.class,
|
||||||
|
long.class, long.class, String.class, WalletRefundRequest.class).getAnnotation(PreAuthorize.class).value())
|
||||||
|
.isEqualTo("hasAuthority('platform:finance:customer-account:adjust')");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空说明或非法幂等键在触达资金前失败。 */
|
||||||
|
@Test void missingReasonAndIdempotencyAreRejected() {
|
||||||
|
assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, " ", request)).isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, "key",
|
||||||
|
new WalletRefundRequest(BigDecimal.ONE, " "))).isInstanceOf(IllegalArgumentException.class);
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthenticatedUser user(String app, long tenant) {
|
||||||
|
return new AuthenticatedUser(7L, tenant, 0L, null, "finance", app, "WEB",
|
||||||
|
Set.of("platform:finance:customer-account:adjust"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import com.baiyue.finance.client.FinanceFileClient;
|
||||||
|
import com.baiyue.finance.dto.FinanceWalletDtos.*;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.h2.jdbcx.JdbcDataSource;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** 真实资金服务、隔离数据库及事务验证海外费用释放、实扣与重复操作。 */
|
||||||
|
class OverseasFeeWalletClosureTest {
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
private FinanceWalletService wallet;
|
||||||
|
private TransactionTemplate transaction;
|
||||||
|
|
||||||
|
/** 复用正式资金表定义,所有造数只进入本测试独立内存库。 */
|
||||||
|
@BeforeEach void setup() throws Exception {
|
||||||
|
var dataSource = new JdbcDataSource();
|
||||||
|
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
|
||||||
|
jdbc = new JdbcTemplate(dataSource);
|
||||||
|
transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
|
||||||
|
var sql = Files.readString(Path.of("src/main/resources/db/finance.sql"));
|
||||||
|
for (var table : List.of("fin_wallet_account", "fin_wallet_hold", "fin_wallet_ledger", "fin_idempotency_record", "fin_audit_log")) {
|
||||||
|
var matcher = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?\\) ENGINE=[^;]+;").matcher(sql);
|
||||||
|
assertThat(matcher.find()).as(table).isTrue();
|
||||||
|
// H2 使用同一列及约束,但去除 MySQL 引擎与更新时间自动生成,避免多列生成键差异。
|
||||||
|
var ddl = matcher.group().replaceAll("(?s) ENGINE=.*", "")
|
||||||
|
.replaceAll("(?i) UNSIGNED", "")
|
||||||
|
.replaceAll("(?i) ON UPDATE CURRENT_TIMESTAMP\\(3\\)", "")
|
||||||
|
.replaceAll("(?i) DEFAULT CURRENT_TIMESTAMP\\(3\\)", "")
|
||||||
|
.replaceAll("(?i)((?:created_at|updated_at) DATETIME\\(3\\)) NOT NULL", "$1 NULL");
|
||||||
|
jdbc.execute(ddl);
|
||||||
|
}
|
||||||
|
var numbers = mock(FinanceDocumentNumberGenerator.class);
|
||||||
|
var sequence = new AtomicInteger();
|
||||||
|
when(numbers.next(anyString())).thenAnswer(call -> "AC-TEST-" + sequence.incrementAndGet());
|
||||||
|
wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||||
|
mock(FinanceFileClient.class), numbers, "test", "", "test", "test");
|
||||||
|
jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(9,'CNY',60,0,160,100,0,0,?,0,0,0)", LocalDate.now().plusDays(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 驳回后释放原冻结,实际余额和授信均恢复可用且不发生消费。 */
|
||||||
|
@Test void rejectionReleasesCashAndCreditExactlyOnce() {
|
||||||
|
freeze();
|
||||||
|
assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("80.00");
|
||||||
|
assertThat(wallet.account(9).creditFrozen()).isEqualByComparingTo("20.00");
|
||||||
|
var request = new WalletHoldActionRequest("金额录错,驳回费用");
|
||||||
|
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
|
||||||
|
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
|
||||||
|
var account = wallet.account(9);
|
||||||
|
assertThat(account.actualBalance()).isEqualByComparingTo("60.00");
|
||||||
|
assertThat(account.frozenBalance()).isZero(); assertThat(account.creditFrozen()).isZero();
|
||||||
|
assertThat(account.availableBalance()).isEqualByComparingTo("160.00");
|
||||||
|
assertThat(count("CONSUMPTION")).isZero(); assertThat(count("UNFREEZE")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扣款事务失败后可重试原请求,多次点击或不同请求键均不重复消费。 */
|
||||||
|
@Test void failedSettlementRollsBackAndRetryConsumesOnlyOnce() {
|
||||||
|
freeze();
|
||||||
|
var request = new WalletHoldActionRequest("审核通过扣款");
|
||||||
|
assertThatThrownBy(() -> transaction.execute(status -> {
|
||||||
|
wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request);
|
||||||
|
throw new IllegalStateException("模拟扣款事务提交前异常");
|
||||||
|
})).hasMessageContaining("模拟扣款");
|
||||||
|
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
|
||||||
|
assertThat(count("CONSUMPTION")).isZero();
|
||||||
|
for (int repeat = 0; repeat < 3; repeat++)
|
||||||
|
transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request));
|
||||||
|
assertThatThrownBy(() -> transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "another-key", request)))
|
||||||
|
.hasMessageContaining("已处理");
|
||||||
|
assertThat(wallet.account(9).actualBalance()).isZero();
|
||||||
|
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
|
||||||
|
assertThat(wallet.account(9).frozenBalance()).isZero(); assertThat(count("CONSUMPTION")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第一版错扣退回复用手工充值;原因、关联单号和操作人进入资金流水。 */
|
||||||
|
@Test void manualCreditRetainsCorrectionReasonAndReplaysWithoutDoubleCredit() {
|
||||||
|
var request = new AccountAdjustmentRequest("RECHARGE", new BigDecimal("12.00"), LocalDate.now(),
|
||||||
|
"OTHER", "TR-001 / 费用701", "退回多收操作费", 0);
|
||||||
|
var first = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
|
||||||
|
var second = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
|
||||||
|
assertThat(second.ledgerId()).isEqualTo(first.ledgerId());
|
||||||
|
assertThat(first.remark()).isEqualTo("退回多收操作费");
|
||||||
|
assertThat(first.referenceNo()).isEqualTo("TR-001 / 费用701");
|
||||||
|
assertThat(first.operatorName()).isEqualTo("财务员");
|
||||||
|
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("72.00");
|
||||||
|
assertThat(count("RECHARGE")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 部分退款优先还授信,累计退款不能超过原消费,原消费事实保持不变。 */
|
||||||
|
@Test void linkedRefundRestoresCreditThenCashAndCannotExceedOriginalConsumption() {
|
||||||
|
freeze();
|
||||||
|
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("审核扣款")));
|
||||||
|
long original = hold.actionLedgerId();
|
||||||
|
var first = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
|
||||||
|
var replay = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
|
||||||
|
assertThat(replay.ledgerId()).isEqualTo(first.ledgerId());
|
||||||
|
assertThat(first.changeType()).isEqualTo("REFUND");
|
||||||
|
assertThat(first.sourceType()).isEqualTo("BUSINESS_REFUND");
|
||||||
|
assertThat(first.referenceNo()).isEqualTo(jdbc.queryForObject("SELECT business_no FROM fin_wallet_ledger WHERE id=?", String.class, original));
|
||||||
|
assertThat(wallet.account(9).creditUsed()).isZero();
|
||||||
|
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("5.00");
|
||||||
|
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", BigDecimal.ONE, 7, "财务", "变更金额"))).hasMessageContaining("幂等");
|
||||||
|
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-too-much", "refund-too-much", new BigDecimal("56"), 7, "财务", "退款"))).hasMessageContaining("超过");
|
||||||
|
transaction.execute(status -> wallet.refundConsumption(9, original, "refund-2", "refund-key-2", new BigDecimal("55"), 7, "财务", "余款退款"));
|
||||||
|
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
|
||||||
|
assertThat(count("REFUND")).isEqualTo(2);
|
||||||
|
assertThat(count("CONSUMPTION")).isEqualTo(1);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger WHERE id=?", BigDecimal.class, original)).isEqualByComparingTo("-80.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非消费流水、跨客户原流水及非正金额均不能改变余额。 */
|
||||||
|
@Test void linkedRefundRejectsWrongCustomerNonConsumptionAndInvalidAmount() {
|
||||||
|
freeze();
|
||||||
|
long frozenLedger = jdbc.queryForObject("SELECT id FROM fin_wallet_ledger WHERE change_type='FREEZE'", Long.class);
|
||||||
|
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, frozenLedger, "bad", "bad", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("成功业务扣款");
|
||||||
|
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
|
||||||
|
jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(10,'CNY',0,0,0,0,0,0,0,0,0)");
|
||||||
|
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(10, hold.actionLedgerId(), "wrong-customer", "wrong-customer", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("不存在");
|
||||||
|
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "zero", "zero", BigDecimal.ZERO, 7, "财务", "退款"))).isInstanceOf(RuntimeException.class);
|
||||||
|
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "blank", "blank", BigDecimal.ONE, 7, "财务", " "))).hasMessageContaining("原因");
|
||||||
|
assertThat(count("REFUND")).isZero();
|
||||||
|
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
|
||||||
|
assertThat(wallet.account(10).actualBalance()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不同请求键的并发退款同样受原消费可退余额约束。 */
|
||||||
|
@Test void concurrentRefundsSerializeOnAccountAndNeverOverRefund() throws Exception {
|
||||||
|
freeze();
|
||||||
|
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
|
||||||
|
var start = new java.util.concurrent.CountDownLatch(1);
|
||||||
|
var executor = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
var results = new java.util.ArrayList<java.util.concurrent.Future<Boolean>>();
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
final String reference = "concurrent-" + i;
|
||||||
|
results.add(executor.submit(() -> {
|
||||||
|
start.await();
|
||||||
|
try {
|
||||||
|
transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), reference, reference, new BigDecimal("50"), 7, "财务", "并发退款"));
|
||||||
|
return true;
|
||||||
|
} catch (FinanceEventException ex) {
|
||||||
|
assertThat(ex.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
start.countDown();
|
||||||
|
int succeeded = 0;
|
||||||
|
for (var result : results) if (result.get(10, java.util.concurrent.TimeUnit.SECONDS)) succeeded++;
|
||||||
|
assertThat(succeeded).isEqualTo(1);
|
||||||
|
assertThat(count("REFUND")).isEqualTo(1);
|
||||||
|
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("30.00");
|
||||||
|
} finally { executor.shutdownNow(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冻结始终沿用同一海外费用业务身份。 */
|
||||||
|
private void freeze() {
|
||||||
|
transaction.execute(status -> wallet.freeze(9, "freeze", new WalletFreezeRequest(
|
||||||
|
new BigDecimal("80.00"), "OVERSEAS_MANUAL_FEE", "701-1", "海外费用")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按真实流水类型统计资金影响,避免只检查服务返回状态。 */
|
||||||
|
private int count(String type) {
|
||||||
|
return jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=9 AND change_type=?", Integer.class, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
||||||
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
|
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
|
||||||
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
|
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
|
||||||
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
|
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认 `ACTUAL` 不调整尺寸,可启用的调整算法为 `ADD/MULTIPLY` |
|
||||||
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
|
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;`POST /fees/preview` 与正式计价共用算法,只读返回当前选择值的计费量、参考单价和合计,不生成费用版本或改变履约、财务状态 |
|
||||||
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
|
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
|
||||||
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
|
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
|
||||||
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
|
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
|
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
|
||||||
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
|
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
|
||||||
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
|
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
|
||||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ACTUAL` 不调整尺寸,仓库修正值替换对应实测边;`ADD` 以修正后的实测尺寸为基准对三个维度分别增加命中箱规的尺寸值;`MULTIPLY` 使用选定的启用系数配置,未指定配置时取当前客户唯一默认配置,由服务端按规格档位选择系数并分别作用于长宽高,体积按系数三次方变化。设备与人工录入共用计算入口,行明确选择的算法优先,否则仅使用客户默认算法;预置或保存系数配置不等于启用该算法。测量行必须保存实际尺寸、选用规则和算法快照,修改确认箱数复用已保存单箱快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||||
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
|
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
|
||||||
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package com.baiyue.firstleg.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.service.SharedFeeCatalogService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 公共费用目录内部只读入口,禁止借用平台员工令牌跨服务查询。 */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/internal/firstleg/v1/fee-items")
|
||||||
|
public class InternalFeeCatalogController {
|
||||||
|
private final SharedFeeCatalogService service;
|
||||||
|
private final byte[] expectedToken;
|
||||||
|
|
||||||
|
/** 绑定费用目录服务与内部身份凭证,空凭证始终拒绝访问。 */
|
||||||
|
public InternalFeeCatalogController(SharedFeeCatalogService service,
|
||||||
|
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||||
|
this.service = service;
|
||||||
|
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回适用海外仓收付方向的启用项目。 */
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<SharedFeeCatalogService.Item>> list(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
@RequestParam(defaultValue = "RECEIVABLE") String direction) {
|
||||||
|
requireToken(token);
|
||||||
|
return ApiResponse.success(service.list(direction));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在费用创建时验证实时项目资格。 */
|
||||||
|
@GetMapping("/{code}")
|
||||||
|
public ApiResponse<SharedFeeCatalogService.Item> detail(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
@PathVariable String code, @RequestParam String direction) {
|
||||||
|
requireToken(token);
|
||||||
|
return ApiResponse.success(service.requireActive(code, direction));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用常量时间比较内部凭证,未配置凭证时关闭入口。 */
|
||||||
|
private void requireToken(String token) {
|
||||||
|
if (expectedToken.length == 0 || token == null
|
||||||
|
|| !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||||
|
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -7,6 +7,8 @@ import com.baiyue.common.core.api.ApiResponse;
|
|||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
|
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
|
||||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||||
|
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
|
||||||
|
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
|
||||||
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
|
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
|
||||||
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
|
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
|
||||||
import com.baiyue.firstleg.service.FbaPricingService;
|
import com.baiyue.firstleg.service.FbaPricingService;
|
||||||
@@ -34,6 +36,16 @@ public class PlatformFbaPricingController {
|
|||||||
@Resource
|
@Resource
|
||||||
private FbaPricingService service;
|
private FbaPricingService service;
|
||||||
|
|
||||||
|
/** 预览当前重量、体积对应的计费量和价格,不生成费用版本或改变履约状态。 */
|
||||||
|
@PostMapping("/preview")
|
||||||
|
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')")
|
||||||
|
public ApiResponse<FeePreviewResponse> preview(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||||
|
@PathVariable long inboundId, @Valid @RequestBody FeePreviewRequest request) {
|
||||||
|
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||||
|
return ApiResponse.success(service.preview(scope, inboundId, request));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/calculate")
|
@PostMapping("/calculate")
|
||||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
|
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
|
||||||
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
|
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||||
|
|||||||
@@ -76,6 +76,25 @@ public final class FbaPricingDtos {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前输入的只读预览,人工单价仅用于预览金额,不写入费用。 */
|
||||||
|
public record FeePreviewRequest(
|
||||||
|
LocalDateTime pricingAt,
|
||||||
|
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
|
||||||
|
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
|
||||||
|
@Size(max = 32) String weightSource,
|
||||||
|
@Size(max = 32) String volumeSource,
|
||||||
|
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedUnitPrice
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 当前选择值的只读计费预览,不包含费用版本或持久化标识。 */
|
||||||
|
public record FeePreviewResponse(
|
||||||
|
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
|
||||||
|
BigDecimal chargeableQuantity, String chargeUnit,
|
||||||
|
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
|
||||||
|
BigDecimal unitPrice, BigDecimal additionalAmount, BigDecimal totalAmount,
|
||||||
|
String currency, boolean priceMatched
|
||||||
|
) {}
|
||||||
|
|
||||||
public record ManualQuoteRequest(
|
public record ManualQuoteRequest(
|
||||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
|
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
|
||||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
|
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.baiyue.firstleg.entity;
|
package com.baiyue.firstleg.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -34,6 +36,7 @@ public class InboundMeasurementItemEntity {
|
|||||||
private BigDecimal volumeCbm;
|
private BigDecimal volumeCbm;
|
||||||
private BigDecimal totalVolumeCbm;
|
private BigDecimal totalVolumeCbm;
|
||||||
private BigDecimal volumeWeightKg;
|
private BigDecimal volumeWeightKg;
|
||||||
|
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private Long chargeRuleId;
|
private Long chargeRuleId;
|
||||||
private String coefficientMode;
|
private String coefficientMode;
|
||||||
private String calculationRuleVersion;
|
private String calculationRuleVersion;
|
||||||
@@ -44,8 +47,12 @@ public class InboundMeasurementItemEntity {
|
|||||||
private BigDecimal outerLengthCm;
|
private BigDecimal outerLengthCm;
|
||||||
private BigDecimal outerWidthCm;
|
private BigDecimal outerWidthCm;
|
||||||
private BigDecimal outerHeightCm;
|
private BigDecimal outerHeightCm;
|
||||||
|
// 清空修正表示恢复对应实测边,必须显式写入 NULL,不能被默认的非空更新策略忽略。
|
||||||
|
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private BigDecimal manualOuterLengthCm;
|
private BigDecimal manualOuterLengthCm;
|
||||||
|
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private BigDecimal manualOuterWidthCm;
|
private BigDecimal manualOuterWidthCm;
|
||||||
|
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private BigDecimal manualOuterHeightCm;
|
private BigDecimal manualOuterHeightCm;
|
||||||
private String remark;
|
private String remark;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
+2
-1
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
|
|||||||
String providerNodeCode, String standardNode,
|
String providerNodeCode, String standardNode,
|
||||||
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||||
LocalDateTime actualAt, String locationCode, String locationName,
|
LocalDateTime actualAt, String locationCode, String locationName,
|
||||||
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
|
String sourceReference, String rawPayloadHash, LocalDateTime createdAt,
|
||||||
|
String operatorName) {}
|
||||||
|
|
||||||
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
|
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
|
||||||
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
|
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
|
||||||
|
|||||||
+2
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
|
|||||||
private String locationName;
|
private String locationName;
|
||||||
private String sourceReference;
|
private String sourceReference;
|
||||||
private String rawPayloadHash;
|
private String rawPayloadHash;
|
||||||
|
private Long operatorId;
|
||||||
|
private String operatorName;
|
||||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -11,14 +11,15 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
||||||
|
/** 保存不可变节点事实及执行该操作的账号快照。 */
|
||||||
@Insert("""
|
@Insert("""
|
||||||
INSERT INTO fl_carrier_schedule_node_fact
|
INSERT INTO fl_carrier_schedule_node_fact
|
||||||
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
|
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
|
||||||
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
|
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,operator_id,operator_name,created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
|
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
|
||||||
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
|
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
|
||||||
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
|
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
|
||||||
CURRENT_TIMESTAMP(3))
|
CURRENT_TIMESTAMP(3))
|
||||||
""")
|
""")
|
||||||
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
|
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
|
||||||
|
|||||||
+17
-9
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
|
|||||||
|
|
||||||
public long customerId(long id) { return required(id).getCustomerId(); }
|
public long customerId(long id) { return required(id).getCustomerId(); }
|
||||||
|
|
||||||
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
|
/** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
|
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
|
||||||
long containerBookingId,
|
long containerBookingId,
|
||||||
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
|
|||||||
persistenceStage = "NODE_FACT";
|
persistenceStage = "NODE_FACT";
|
||||||
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
sha256("OUTBOUND_CONFIRM|" + containerBookingId), operatorId, operatorName);
|
||||||
if (departed != null) {
|
if (departed != null) {
|
||||||
persistenceStage = "CROSS_REGION_OUTBOX";
|
persistenceStage = "CROSS_REGION_OUTBOX";
|
||||||
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||||
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
|
|||||||
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
|
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
|
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
|
||||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||||
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
|
|||||||
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
|
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
|
||||||
request.providerNodeCode(), node,
|
request.providerNodeCode(), node,
|
||||||
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
||||||
request.locationName(), request.sourceReference(), request.rawPayloadHash());
|
request.locationName(), request.sourceReference(), request.rawPayloadHash(), null, null);
|
||||||
if (fact == null) {
|
if (fact == null) {
|
||||||
return detail(value);
|
return detail(value);
|
||||||
}
|
}
|
||||||
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
|
|||||||
return detail(value);
|
return detail(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
|
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
|
||||||
ManualNodeRequest request) {
|
ManualNodeRequest request) {
|
||||||
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
|
|||||||
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
|
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
|
||||||
request.observedAt(),
|
request.observedAt(),
|
||||||
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
||||||
"MANUAL:" + operatorId, hash(operation, id, request));
|
"MANUAL:" + operatorId, hash(operation, id, request), operatorId, operatorName);
|
||||||
if (fact == null) {
|
if (fact == null) {
|
||||||
return complete(id, operatorId, key, detail(value));
|
return complete(id, operatorId, key, detail(value));
|
||||||
}
|
}
|
||||||
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
|
|||||||
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
|
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
|
||||||
String operatorName, String key,
|
String operatorName, String key,
|
||||||
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
|
|||||||
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
|
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
|
||||||
"MANUAL", providerEventId,
|
"MANUAL", providerEventId,
|
||||||
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
|
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
|
||||||
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
|
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash,
|
||||||
|
operatorId, operatorName);
|
||||||
if (fact != null && query != null) {
|
if (fact != null && query != null) {
|
||||||
CarrierScheduleQueryEntity before = copy(query);
|
CarrierScheduleQueryEntity before = copy(query);
|
||||||
requireManualActualAdvance(query, node, request.actualAt());
|
requireManualActualAdvance(query, node, request.actualAt());
|
||||||
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
|
|||||||
repository.histories(value.getId()).stream().map(this::history).toList());
|
repository.histories(value.getId()).stream().map(this::history).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
|
||||||
private NodeFact node(CarrierScheduleNodeFactEntity value) {
|
private NodeFact node(CarrierScheduleNodeFactEntity value) {
|
||||||
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
|
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
|
||||||
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
|
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
|
||||||
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
|
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
|
||||||
value.getRawPayloadHash(), value.getCreatedAt());
|
value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
|
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
|
||||||
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
|
|||||||
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为查询任务追加节点事实并透传人工操作账号。 */
|
||||||
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
|
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
|
||||||
String providerNodeCode,
|
String providerNodeCode,
|
||||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||||
LocalDateTime actualAt, String locationCode, String locationName,
|
LocalDateTime actualAt, String locationCode, String locationName,
|
||||||
String sourceReference, String rawPayloadHash) {
|
String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
|
||||||
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
|
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
|
||||||
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
|
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
|
||||||
sourceReference, rawPayloadHash);
|
sourceReference, rawPayloadHash, operatorId, operatorName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
|
||||||
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
|
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
|
||||||
String providerCode, String providerEventId, String providerNodeCode,
|
String providerCode, String providerEventId, String providerNodeCode,
|
||||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||||
LocalDateTime actualAt, String locationCode, String locationName,
|
LocalDateTime actualAt, String locationCode, String locationName,
|
||||||
String sourceReference, String rawPayloadHash) {
|
String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
|
||||||
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
|
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
|
||||||
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
|
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
|
||||||
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
|
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
|
||||||
@@ -589,6 +596,7 @@ public class CarrierScheduleQueryService {
|
|||||||
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
|
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
|
||||||
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||||
|
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
|
||||||
if (repository.insertIfAbsent(fact)) return fact;
|
if (repository.insertIfAbsent(fact)) return fact;
|
||||||
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
|
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
|
||||||
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
|
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
|
||||||
|
|||||||
@@ -383,11 +383,12 @@ public class FbaPricingRepository {
|
|||||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
||||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||||
.in(InboundPricingEntity::getProcessStatus,
|
.in(InboundPricingEntity::getProcessStatus,
|
||||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ public class FeeItemRepository {
|
|||||||
.eq(FeeItemEntity::getFeeCode, feeCode));
|
.eq(FeeItemEntity::getFeeCode, feeCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 海外录费仅提供平台启用的国际仓库收付费项目,不复制费用目录。 */
|
||||||
|
public List<FeeItemEntity> listActiveInternationalWarehouseItems(String direction) {
|
||||||
|
return feeItems.selectList(new LambdaQueryWrapper<FeeItemEntity>()
|
||||||
|
.eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID)
|
||||||
|
.eq(FeeItemEntity::getStatus, "ACTIVE")
|
||||||
|
.eq(FeeItemEntity::getTradeScope, "INTERNATIONAL")
|
||||||
|
.eq("RECEIVABLE".equals(direction), FeeItemEntity::getWarehouseReceivable, true)
|
||||||
|
.eq("PAYABLE".equals(direction), FeeItemEntity::getWarehousePayable, true)
|
||||||
|
.orderByAsc(FeeItemEntity::getCategory)
|
||||||
|
.orderByAsc(FeeItemEntity::getFeeTypeName));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean itemNameExists(String tradeScope, String feeTypeName, Long excludedId) {
|
public boolean itemNameExists(String tradeScope, String feeTypeName, Long excludedId) {
|
||||||
return feeItems.selectCount(new LambdaQueryWrapper<FeeItemEntity>()
|
return feeItems.selectCount(new LambdaQueryWrapper<FeeItemEntity>()
|
||||||
.eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID)
|
.eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID)
|
||||||
|
|||||||
+5
-3
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-63
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-32
@@ -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())); }
|
||||||
|
|||||||
@@ -44,21 +44,22 @@ public class CartonMeasurementService {
|
|||||||
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
|
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
|
||||||
private final MeasurementImageClient imageClient;
|
private final MeasurementImageClient imageClient;
|
||||||
private final TransactionTemplate transactionTemplate;
|
private final TransactionTemplate transactionTemplate;
|
||||||
private final CustomerDimensionWeightingService dimensionWeighting;
|
private final MeasurementBatchService measurementBatchService;
|
||||||
|
|
||||||
@Value("${baiyue.security.internal-service-token}")
|
@Value("${baiyue.security.internal-service-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
|
|
||||||
|
/** 设备及手工单箱录入复用测量批次的计算规则。 */
|
||||||
public CartonMeasurementService(InboundMeasurementRepository repository,
|
public CartonMeasurementService(InboundMeasurementRepository repository,
|
||||||
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
|
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
|
||||||
MeasurementImageClient imageClient,
|
MeasurementImageClient imageClient,
|
||||||
TransactionTemplate transactionTemplate,
|
TransactionTemplate transactionTemplate,
|
||||||
CustomerDimensionWeightingService dimensionWeighting) {
|
MeasurementBatchService measurementBatchService) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.resolverProvider = resolverProvider;
|
this.resolverProvider = resolverProvider;
|
||||||
this.imageClient = imageClient;
|
this.imageClient = imageClient;
|
||||||
this.transactionTemplate = transactionTemplate;
|
this.transactionTemplate = transactionTemplate;
|
||||||
this.dimensionWeighting = dimensionWeighting;
|
this.measurementBatchService = measurementBatchService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
|
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
|
||||||
@@ -178,6 +179,7 @@ public class CartonMeasurementService {
|
|||||||
return result(measurement, item, false);
|
return result(measurement, item, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存设备原始尺寸与证据,按客户默认算法或已保存的行算法生成计费快照。 */
|
||||||
private UpsertOutcome persist(long tenantId, long operatorId,
|
private UpsertOutcome persist(long tenantId, long operatorId,
|
||||||
CartonLabelBindingResolver.Binding binding,
|
CartonLabelBindingResolver.Binding binding,
|
||||||
String boxCode, CartonMeasurementCommand command, long imageFileId,
|
String boxCode, CartonMeasurementCommand command, long imageFileId,
|
||||||
@@ -228,7 +230,7 @@ public class CartonMeasurementService {
|
|||||||
item.setMachineNo(command.machineNo().trim());
|
item.setMachineNo(command.machineNo().trim());
|
||||||
item.setDeviceMeasuredAt(command.measuredAt());
|
item.setDeviceMeasuredAt(command.measuredAt());
|
||||||
item.setUpdatedAt(LocalDateTime.now());
|
item.setUpdatedAt(LocalDateTime.now());
|
||||||
applyCalculatedValues(item, dimensionWeighting.load(tenantId));
|
measurementBatchService.calculateCartonDimensions(item);
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
repository.insertItem(item);
|
repository.insertItem(item);
|
||||||
@@ -265,33 +267,12 @@ public class CartonMeasurementService {
|
|||||||
return measurement;
|
return measurement;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyCalculatedValues(InboundMeasurementItemEntity item,
|
/** 仅修改确认箱数时复用已保存的单箱快照,不重新套用当前客户规则。 */
|
||||||
CustomerDimensionWeightingService.RuleSet dimensionRules) {
|
|
||||||
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolveDeviceMeasurement(
|
|
||||||
item.getLengthCm(), item.getWidthCm(), item.getHeightCm());
|
|
||||||
BigDecimal unitRaw = weighted.rawVolumeCbm();
|
|
||||||
BigDecimal unitVolume = weighted.unitVolumeCbm();
|
|
||||||
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
|
||||||
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
|
|
||||||
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.UNNECESSARY);
|
|
||||||
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
|
|
||||||
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
|
|
||||||
item.setUnitVolumeCbm(unitVolume);
|
|
||||||
item.setVolumeRawCbm(totalRaw);
|
|
||||||
item.setVolumeCbm(totalVolume);
|
|
||||||
item.setTotalVolumeCbm(totalVolume);
|
|
||||||
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
|
||||||
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
|
|
||||||
item.setOuterLengthCm(weighted.outerLengthCm());
|
|
||||||
item.setOuterWidthCm(weighted.outerWidthCm());
|
|
||||||
item.setOuterHeightCm(weighted.outerHeightCm());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) {
|
private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) {
|
||||||
if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm())
|
if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm())
|
||||||
|| !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm())
|
|| !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm())
|
||||||
|| !StringUtils.hasText(item.getCalculationRuleVersion())) {
|
|| !StringUtils.hasText(item.getCalculationRuleVersion())) {
|
||||||
applyCalculatedValues(item, dimensionWeighting.load(item.getTenantId()));
|
measurementBatchService.calculateCartonDimensions(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
||||||
|
|||||||
+18
-20
@@ -25,6 +25,7 @@ public class CustomerDimensionWeightingService {
|
|||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 加载完整四档箱规及客户明确选择的默认算法,配置存在不代表算法已启用。 */
|
||||||
public RuleSet load(long tenantId) {
|
public RuleSet load(long tenantId) {
|
||||||
List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId);
|
List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId);
|
||||||
if (values == null || values.size() != CustomerDimensionBoxType.values().length
|
if (values == null || values.size() != CustomerDimensionBoxType.values().length
|
||||||
@@ -33,7 +34,14 @@ public class CustomerDimensionWeightingService {
|
|||||||
.collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) {
|
.collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积");
|
throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积");
|
||||||
}
|
}
|
||||||
return new RuleSet(values.stream().map(Rule::from).toList());
|
List<String> modes = values.stream().map(value -> value.getDefaultCalculationMode() == null
|
||||||
|
|| value.getDefaultCalculationMode().isBlank() ? "ACTUAL"
|
||||||
|
: value.getDefaultCalculationMode().trim().toUpperCase(java.util.Locale.ROOT))
|
||||||
|
.distinct().toList();
|
||||||
|
if (modes.size() != 1 || !List.of("ACTUAL", "ADD", "MULTIPLY").contains(modes.get(0))) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "客户默认箱规计算方式配置不一致或无效");
|
||||||
|
}
|
||||||
|
return new RuleSet(values.stream().map(Rule::from).toList(), modes.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean invalidRule(CustomerDimensionConfigEntity value) {
|
private static boolean invalidRule(CustomerDimensionConfigEntity value) {
|
||||||
@@ -48,14 +56,22 @@ public class CustomerDimensionWeightingService {
|
|||||||
|
|
||||||
public static final class RuleSet {
|
public static final class RuleSet {
|
||||||
private final List<Rule> rules;
|
private final List<Rule> rules;
|
||||||
|
private final String defaultCalculationMode;
|
||||||
|
|
||||||
private RuleSet(List<Rule> rules) {
|
/** 保存同一客户的箱规和默认算法,供设备及人工测量统一使用。 */
|
||||||
|
private RuleSet(List<Rule> rules, String defaultCalculationMode) {
|
||||||
|
this.defaultCalculationMode = defaultCalculationMode;
|
||||||
this.rules = rules.stream()
|
this.rules = rules.stream()
|
||||||
.sorted(Comparator.comparing(Rule::innerVolume)
|
.sorted(Comparator.comparing(Rule::innerVolume)
|
||||||
.thenComparing(rule -> rule.boxType().ordinal()))
|
.thenComparing(rule -> rule.boxType().ordinal()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回客户已启用的默认算法;ACTUAL 表示尺寸不调整。 */
|
||||||
|
public String defaultCalculationMode() {
|
||||||
|
return defaultCalculationMode;
|
||||||
|
}
|
||||||
|
|
||||||
public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) {
|
public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) {
|
||||||
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
|
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
|
||||||
return rules.stream()
|
return rules.stream()
|
||||||
@@ -66,24 +82,6 @@ public class CustomerDimensionWeightingService {
|
|||||||
.weightedActualDimensions(lengthCm, widthCm, heightCm));
|
.weightedActualDimensions(lengthCm, widthCm, heightCm));
|
||||||
}
|
}
|
||||||
|
|
||||||
public WeightedDimensions resolveDeviceMeasurement(BigDecimal lengthCm, BigDecimal widthCm,
|
|
||||||
BigDecimal heightCm) {
|
|
||||||
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
|
|
||||||
return rules.stream()
|
|
||||||
.filter(rule -> rule.contains(measured))
|
|
||||||
.findFirst()
|
|
||||||
.map(rule -> rule.weightedActualDimensions(lengthCm, widthCm, heightCm))
|
|
||||||
.orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm));
|
|
||||||
}
|
|
||||||
|
|
||||||
private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
|
|
||||||
BigDecimal heightCm) {
|
|
||||||
BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm)
|
|
||||||
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
|
|
||||||
return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm,
|
|
||||||
rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP),
|
|
||||||
"ACTUAL_DIMENSION_V1");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record WeightedDimensions(
|
public record WeightedDimensions(
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import jakarta.annotation.Resource;
|
|||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||||
|
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
|
||||||
|
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
|
||||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||||
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
|
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
|
||||||
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
|
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
|
||||||
@@ -63,6 +65,41 @@ public class FbaPricingService {
|
|||||||
@Resource
|
@Resource
|
||||||
private ContainerTierAmountAllocator containerTierAllocator;
|
private ContainerTierAmountAllocator containerTierAllocator;
|
||||||
|
|
||||||
|
/** 按当前选择值只读预览,复用正式计价和柜级阶梯规则,不写费用、幂等或财务记录。 */
|
||||||
|
public FeePreviewResponse preview(long tenantId, long inboundId, FeePreviewRequest request) {
|
||||||
|
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
|
||||||
|
requireMeasurementSources(tenantId, inboundId, request.weightSource(), request.volumeSource());
|
||||||
|
BigDecimal weight = selectedWeight(inbound, request.selectedWeightKg());
|
||||||
|
BigDecimal volume = selectedVolume(inbound, request.selectedVolumeCbm());
|
||||||
|
PricingPolicy policy = pricingPolicy(inbound,
|
||||||
|
request.pricingAt() == null ? LocalDateTime.now(clock) : request.pricingAt());
|
||||||
|
try {
|
||||||
|
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
|
||||||
|
BigDecimal additional = estimate.rate().getAdditionalFee() == null ? BigDecimal.ZERO
|
||||||
|
: estimate.rate().getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal subtotal = request.selectedUnitPrice() == null
|
||||||
|
|| request.selectedUnitPrice().compareTo(estimate.unitPrice()) == 0
|
||||||
|
? estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)
|
||||||
|
: estimate.chargeableQuantity().multiply(request.selectedUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||||
|
return new FeePreviewResponse(weight, volume, estimate.chargeableQuantity(), estimate.chargeUnit(),
|
||||||
|
estimate.conversionFactor(), estimate.roundingScale(), estimate.roundingMode(),
|
||||||
|
estimate.unitPrice(), additional,
|
||||||
|
subtotal.add(additional),
|
||||||
|
estimate.currency(), true);
|
||||||
|
} catch (BizException exception) {
|
||||||
|
// 仅价格未命中且可人工定价的 FBA 返回计费量;其他业务错误继续明确拒绝。
|
||||||
|
if (exception.getResultCode() != ResultCode.NOT_FOUND || !"FBA".equals(inbound.getDestinationType())) {
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363");
|
||||||
|
return new FeePreviewResponse(weight, volume, manualChargeableQuantity(policy.chargeMode(), weight, volume),
|
||||||
|
policy.chargeMode(), factor, 6, "HALF_UP", null,
|
||||||
|
BigDecimal.ZERO.setScale(MONEY_SCALE), request.selectedUnitPrice() == null ? null
|
||||||
|
: manualChargeableQuantity(policy.chargeMode(), weight, volume).multiply(request.selectedUnitPrice())
|
||||||
|
.setScale(MONEY_SCALE, RoundingMode.HALF_UP), "CNY", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
||||||
LocalDateTime requestedAt, String idempotencyKey) {
|
LocalDateTime requestedAt, String idempotencyKey) {
|
||||||
@@ -77,6 +114,7 @@ public class FbaPricingService {
|
|||||||
selectedVolumeCbm, null, null, idempotencyKey);
|
selectedVolumeCbm, null, null, idempotencyKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复用预览的计价规则生成费用版本,仅正式确认流程写入计费事实。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
||||||
LocalDateTime requestedAt, BigDecimal selectedWeightKg,
|
LocalDateTime requestedAt, BigDecimal selectedWeightKg,
|
||||||
@@ -105,38 +143,11 @@ public class FbaPricingService {
|
|||||||
throw conflict("客户已确认,不允许再修改或重新发送费用");
|
throw conflict("客户已确认,不允许再修改或重新发送费用");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
|
var measurement = requireMeasurementSources(tenantId, inboundId, weightSource, volumeSource);
|
||||||
boolean actualMeasurementSource = "ACTUAL".equalsIgnoreCase(weightSource)
|
|
||||||
|| "ACTUAL".equalsIgnoreCase(volumeSource);
|
|
||||||
boolean outerMeasurementSource = "OUTER".equalsIgnoreCase(weightSource)
|
|
||||||
|| "OUTER".equalsIgnoreCase(volumeSource);
|
|
||||||
if ((actualMeasurementSource || outerMeasurementSource) && measurement == null) {
|
|
||||||
throw conflict(actualMeasurementSource
|
|
||||||
? "实际测量数据不存在,不能生成对客费用"
|
|
||||||
: "外箱规测量批次不存在,不能生成对客费用");
|
|
||||||
}
|
|
||||||
if (actualMeasurementSource || outerMeasurementSource) {
|
|
||||||
var measurementItems = measurementRepository.listItems(tenantId, measurement.getId());
|
|
||||||
if (actualMeasurementSource && measurementItems.isEmpty()) {
|
|
||||||
throw conflict("实际测量明细不存在,不能生成对客费用");
|
|
||||||
}
|
|
||||||
boolean incomplete = outerMeasurementSource && (measurementItems.isEmpty() || measurementItems.stream().anyMatch(item ->
|
|
||||||
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|
|
||||||
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|
|
||||||
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|
|
||||||
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
|
|
||||||
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
|
|
||||||
}
|
|
||||||
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
|
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
|
||||||
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
|
BigDecimal weight = selectedWeight(inbound, selectedWeightKg);
|
||||||
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
BigDecimal volume = selectedVolume(inbound, selectedVolumeCbm);
|
||||||
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
|
||||||
pricingTaxMode(inbound), policy.chargeMode(),
|
|
||||||
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
|
|
||||||
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
|
|
||||||
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
|
|
||||||
BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP);
|
|
||||||
estimate = applyContainerCustomerTier(inbound, estimate, weight, volume);
|
|
||||||
RateRow rate = estimate.rate();
|
RateRow rate = estimate.rate();
|
||||||
BigDecimal chargeable = estimate.chargeableQuantity();
|
BigDecimal chargeable = estimate.chargeableQuantity();
|
||||||
BigDecimal converted = chargeable;
|
BigDecimal converted = chargeable;
|
||||||
@@ -179,6 +190,7 @@ public class FbaPricingService {
|
|||||||
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId,
|
public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId,
|
||||||
ManualQuoteRequest request, String idempotencyKey) {
|
ManualQuoteRequest request, String idempotencyKey) {
|
||||||
@@ -219,9 +231,7 @@ public class FbaPricingService {
|
|||||||
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
|
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
|
||||||
String unit = policy.chargeMode();
|
String unit = policy.chargeMode();
|
||||||
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
|
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
|
||||||
BigDecimal quantity = ("KG".equals(unit) ? weight.max(volume.multiply(factor))
|
BigDecimal quantity = manualChargeableQuantity(unit, weight, volume);
|
||||||
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
|
|
||||||
.setScale(6, RoundingMode.HALF_UP);
|
|
||||||
BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||||
int version = repository.nextFeeVersion(tenantId, inboundId);
|
int version = repository.nextFeeVersion(tenantId, inboundId);
|
||||||
InboundFeeEntity fee = new InboundFeeEntity();
|
InboundFeeEntity fee = new InboundFeeEntity();
|
||||||
@@ -267,6 +277,61 @@ public class FbaPricingService {
|
|||||||
return response(inbound, fee, request.unitPrice(), nextStatus);
|
return response(inbound, fee, request.unitPrice(), nextStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
|
||||||
|
private ActivePriceBookEstimator.Estimate estimateFee(long tenantId, InboundPricingEntity inbound,
|
||||||
|
PricingPolicy policy, BigDecimal weight, BigDecimal volume) {
|
||||||
|
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
|
||||||
|
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
||||||
|
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
||||||
|
pricingTaxMode(inbound), policy.chargeMode(), weight, volume));
|
||||||
|
return applyContainerCustomerTier(inbound, estimate, weight, volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计费重量统一保留三位精度,预览与最终费用使用同一基数。 */
|
||||||
|
private BigDecimal selectedWeight(InboundPricingEntity inbound, BigDecimal selected) {
|
||||||
|
BigDecimal weight = selected == null ? inbound.getActualNetWeightKg() : selected;
|
||||||
|
if (weight == null || weight.signum() <= 0) throw conflict("计费重量必须大于0");
|
||||||
|
return weight.setScale(3, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计费体积统一保留四位精度,预览与最终费用使用同一基数。 */
|
||||||
|
private BigDecimal selectedVolume(InboundPricingEntity inbound, BigDecimal selected) {
|
||||||
|
BigDecimal volume = selected == null ? inbound.getActualVolumeCbm() : selected;
|
||||||
|
if (volume == null || volume.signum() <= 0) throw conflict("计费体积必须大于0");
|
||||||
|
return volume.setScale(4, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人工定价的重量与体积换算规则由服务端统一提供给预览和正式报价。 */
|
||||||
|
private BigDecimal manualChargeableQuantity(String unit, BigDecimal weight, BigDecimal volume) {
|
||||||
|
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
|
||||||
|
return ("KG".equals(unit) ? weight.max(volume.multiply(factor))
|
||||||
|
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
|
||||||
|
.setScale(6, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅验证测量来源及保存快照完整性,不写入或重新计算测量数据。 */
|
||||||
|
private com.baiyue.firstleg.entity.InboundMeasurementEntity requireMeasurementSources(
|
||||||
|
long tenantId, long inboundId, String weightSource, String volumeSource) {
|
||||||
|
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
|
||||||
|
boolean actual = "ACTUAL".equalsIgnoreCase(weightSource) || "ACTUAL".equalsIgnoreCase(volumeSource);
|
||||||
|
boolean outer = "OUTER".equalsIgnoreCase(weightSource) || "OUTER".equalsIgnoreCase(volumeSource);
|
||||||
|
if ((actual || outer) && measurement == null) {
|
||||||
|
throw conflict(actual ? "实际测量数据不存在,不能生成对客费用" : "外箱规测量批次不存在,不能生成对客费用");
|
||||||
|
}
|
||||||
|
if (actual || outer) {
|
||||||
|
var items = measurementRepository.listItems(tenantId, measurement.getId());
|
||||||
|
if (actual && items.isEmpty()) throw conflict("实际测量明细不存在,不能生成对客费用");
|
||||||
|
if (outer && (items.isEmpty() || items.stream().anyMatch(item ->
|
||||||
|
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|
||||||
|
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|
||||||
|
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|
||||||
|
|| !StringUtils.hasText(item.getCalculationRuleVersion())))) {
|
||||||
|
throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return measurement;
|
||||||
|
}
|
||||||
|
|
||||||
private ActivePriceBookEstimator.Estimate applyContainerCustomerTier(
|
private ActivePriceBookEstimator.Estimate applyContainerCustomerTier(
|
||||||
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
|
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
|
||||||
BigDecimal currentWeight, BigDecimal currentVolume) {
|
BigDecimal currentWeight, BigDecimal currentVolume) {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public class MeasurementBatchService {
|
|||||||
@Resource
|
@Resource
|
||||||
private FbaPricingRepository pricingRepository;
|
private FbaPricingRepository pricingRepository;
|
||||||
|
|
||||||
|
/** 按已选算法保存测量批次及计算快照,不推进履约状态。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
|
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
|
||||||
var inbound = repository.findInbound(command.tenantId(), command.inboundId());
|
var inbound = repository.findInbound(command.tenantId(), command.inboundId());
|
||||||
@@ -68,7 +69,7 @@ public class MeasurementBatchService {
|
|||||||
}
|
}
|
||||||
validateSource(command);
|
validateSource(command);
|
||||||
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
|
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
|
||||||
command, dimensionWeighting.load(command.tenantId()));
|
command.tenantId(), command.items(), dimensionWeighting.load(command.tenantId()));
|
||||||
|
|
||||||
InboundMeasurementEntity measurement;
|
InboundMeasurementEntity measurement;
|
||||||
if (command.measurementId() == null) {
|
if (command.measurementId() == null) {
|
||||||
@@ -93,6 +94,7 @@ public class MeasurementBatchService {
|
|||||||
return measurement;
|
return measurement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 修改批次算法时,以实测及人工修正基数重算,避免重复叠加。 */
|
||||||
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
|
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
|
||||||
List<InboundMeasurementItemEntity> existingItems,
|
List<InboundMeasurementItemEntity> existingItems,
|
||||||
String coefficientMode, Long chargeRuleId) {
|
String coefficientMode, Long chargeRuleId) {
|
||||||
@@ -112,7 +114,7 @@ public class MeasurementBatchService {
|
|||||||
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
|
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
|
||||||
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
|
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
|
||||||
List<InboundMeasurementItemEntity> recalculated = calculateItems(
|
List<InboundMeasurementItemEntity> recalculated = calculateItems(
|
||||||
command, dimensionWeighting.load(tenantId));
|
tenantId, command.items(), dimensionWeighting.load(tenantId));
|
||||||
for (int index = 0; index < existingItems.size(); index++) {
|
for (int index = 0; index < existingItems.size(); index++) {
|
||||||
InboundMeasurementItemEntity target = existingItems.get(index);
|
InboundMeasurementItemEntity target = existingItems.get(index);
|
||||||
InboundMeasurementItemEntity source = recalculated.get(index);
|
InboundMeasurementItemEntity source = recalculated.get(index);
|
||||||
@@ -121,6 +123,7 @@ public class MeasurementBatchService {
|
|||||||
applyTotals(measurement, existingItems);
|
applyTotals(measurement, existingItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按保存的算法重算单行外箱修正,并更新批次汇总。 */
|
||||||
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
|
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
|
||||||
List<InboundMeasurementItemEntity> existingItems, long itemId,
|
List<InboundMeasurementItemEntity> existingItems, long itemId,
|
||||||
BigDecimal outerLengthCm, BigDecimal outerWidthCm,
|
BigDecimal outerLengthCm, BigDecimal outerWidthCm,
|
||||||
@@ -143,14 +146,42 @@ public class MeasurementBatchService {
|
|||||||
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
|
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
|
||||||
target.getRemark())));
|
target.getRemark())));
|
||||||
InboundMeasurementItemEntity recalculated = calculateItems(
|
InboundMeasurementItemEntity recalculated = calculateItems(
|
||||||
command, dimensionWeighting.load(tenantId)).get(0);
|
tenantId, command.items(), dimensionWeighting.load(tenantId)).get(0);
|
||||||
applyRecalculatedItem(target, recalculated, true);
|
applyRecalculatedItem(target, recalculated, true);
|
||||||
applyTotals(measurement, existingItems);
|
applyTotals(measurement, existingItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设备首次测量与复测共用批次算法,保留已有人工修正及明确选择的算法。 */
|
||||||
|
void calculateCartonDimensions(InboundMeasurementItemEntity item) {
|
||||||
|
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
|
||||||
|
item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
|
||||||
|
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getSourceImageFileId(),
|
||||||
|
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
|
||||||
|
item.getManualOuterHeightCm(), item.getChargeRuleId(), item.getCoefficientMode(), item.getRemark());
|
||||||
|
InboundMeasurementItemEntity calculated = calculateItems(item.getTenantId(), List.of(source),
|
||||||
|
dimensionWeighting.load(item.getTenantId())).get(0);
|
||||||
|
copyCalculatedValues(item, calculated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存重算后的行,保留箱码、设备证据和批次身份。 */
|
||||||
private void applyRecalculatedItem(InboundMeasurementItemEntity target,
|
private void applyRecalculatedItem(InboundMeasurementItemEntity target,
|
||||||
InboundMeasurementItemEntity source,
|
InboundMeasurementItemEntity source,
|
||||||
boolean updateManualDimensions) {
|
boolean updateManualDimensions) {
|
||||||
|
copyCalculatedValues(target, source);
|
||||||
|
if (updateManualDimensions) {
|
||||||
|
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
|
||||||
|
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
|
||||||
|
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
|
||||||
|
}
|
||||||
|
target.setUpdatedAt(LocalDateTime.now());
|
||||||
|
if (!repository.updateItem(target)) {
|
||||||
|
throw concurrentModification();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一复制尺寸、体积、材积重及算法快照,不覆盖原始测量值。 */
|
||||||
|
private void copyCalculatedValues(InboundMeasurementItemEntity target,
|
||||||
|
InboundMeasurementItemEntity source) {
|
||||||
target.setGirthCm(source.getGirthCm());
|
target.setGirthCm(source.getGirthCm());
|
||||||
target.setUnitVolumeCbm(source.getUnitVolumeCbm());
|
target.setUnitVolumeCbm(source.getUnitVolumeCbm());
|
||||||
target.setVolumeRawCbm(source.getVolumeRawCbm());
|
target.setVolumeRawCbm(source.getVolumeRawCbm());
|
||||||
@@ -163,15 +194,6 @@ public class MeasurementBatchService {
|
|||||||
target.setOuterLengthCm(source.getOuterLengthCm());
|
target.setOuterLengthCm(source.getOuterLengthCm());
|
||||||
target.setOuterWidthCm(source.getOuterWidthCm());
|
target.setOuterWidthCm(source.getOuterWidthCm());
|
||||||
target.setOuterHeightCm(source.getOuterHeightCm());
|
target.setOuterHeightCm(source.getOuterHeightCm());
|
||||||
if (updateManualDimensions) {
|
|
||||||
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
|
|
||||||
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
|
|
||||||
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
|
|
||||||
}
|
|
||||||
target.setUpdatedAt(LocalDateTime.now());
|
|
||||||
if (!repository.updateItem(target)) {
|
|
||||||
throw concurrentModification();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,10 +279,12 @@ public class MeasurementBatchService {
|
|||||||
measurement.setActualGoodsName(command.actualGoodsName());
|
measurement.setActualGoodsName(command.actualGoodsName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 明确的行算法优先;未选择时仅使用客户默认算法,不因预置系数而自动启用。 */
|
||||||
private List<InboundMeasurementItemEntity> calculateItems(
|
private List<InboundMeasurementItemEntity> calculateItems(
|
||||||
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) {
|
long tenantId, List<MeasurementBatchCommand.Item> sourceItems,
|
||||||
|
CustomerDimensionWeightingService.RuleSet dimensionRules) {
|
||||||
Set<String> itemNumbers = new HashSet<>();
|
Set<String> itemNumbers = new HashSet<>();
|
||||||
return command.items().stream().map(source -> {
|
return sourceItems.stream().map(source -> {
|
||||||
if (!itemNumbers.add(source.itemNo())) {
|
if (!itemNumbers.add(source.itemNo())) {
|
||||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
|
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
|
||||||
}
|
}
|
||||||
@@ -279,10 +303,9 @@ public class MeasurementBatchService {
|
|||||||
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
|
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
|
||||||
baseLength, baseWidth, baseHeight);
|
baseLength, baseWidth, baseHeight);
|
||||||
String requestedMode = normalizeMode(source.coefficientMode());
|
String requestedMode = normalizeMode(source.coefficientMode());
|
||||||
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode)
|
String coefficientMode = requestedMode == null ? dimensionRules.defaultCalculationMode() : requestedMode;
|
||||||
? requireCoefficientRule(command.tenantId(), source.chargeRuleId())
|
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(coefficientMode)
|
||||||
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId()) : null;
|
? requireCoefficientRule(tenantId, source.chargeRuleId()) : null;
|
||||||
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
|
|
||||||
boolean useDimensionRule = "ADD".equals(coefficientMode);
|
boolean useDimensionRule = "ADD".equals(coefficientMode);
|
||||||
BigDecimal addition = weighted.edgeAdditionCm() == null
|
BigDecimal addition = weighted.edgeAdditionCm() == null
|
||||||
? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2));
|
? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2));
|
||||||
@@ -374,10 +397,6 @@ public class MeasurementBatchService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId) {
|
|
||||||
return pricingRepository.findDefaultRule(tenantId, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) {
|
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) {
|
||||||
CustomerChargeRuleEntity rule = ruleId == null
|
CustomerChargeRuleEntity rule = ruleId == null
|
||||||
? pricingRepository.findDefaultRule(tenantId, tenantId)
|
? pricingRepository.findDefaultRule(tenantId, tenantId)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.entity.FeeItemEntity;
|
||||||
|
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 向海外业务提供公共费用目录的只读切片,项目维护仍归头程主数据。 */
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SharedFeeCatalogService {
|
||||||
|
private final FeeItemRepository repository;
|
||||||
|
|
||||||
|
/** 按收付方向返回当前可使用的费用项目。 */
|
||||||
|
public List<Item> list(String direction) {
|
||||||
|
requireDirection(direction);
|
||||||
|
return repository.listActiveInternationalWarehouseItems(direction).stream()
|
||||||
|
.map(SharedFeeCatalogService::snapshot).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入业务费用前重新验证项目资格,停用或方向变化不得沿用旧选项。 */
|
||||||
|
public Item requireActive(String code, String direction) {
|
||||||
|
requireDirection(direction);
|
||||||
|
FeeItemEntity item = repository.findItemByCode(code);
|
||||||
|
if (item == null || !"ACTIVE".equals(item.getStatus())
|
||||||
|
|| !"INTERNATIONAL".equals(item.getTradeScope())
|
||||||
|
|| !("RECEIVABLE".equals(direction)
|
||||||
|
? Boolean.TRUE.equals(item.getWarehouseReceivable())
|
||||||
|
: Boolean.TRUE.equals(item.getWarehousePayable()))) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "费用项目未启用或不适用于本次收费");
|
||||||
|
}
|
||||||
|
return snapshot(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验显式收付方向,防止空方向绕过目录过滤。 */
|
||||||
|
private static void requireDirection(String direction) {
|
||||||
|
if (!"RECEIVABLE".equals(direction) && !"PAYABLE".equals(direction)) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "请选择费用收付方向");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回有限字段,不向其他服务暴露目录实体或审计属性。 */
|
||||||
|
private static Item snapshot(FeeItemEntity item) {
|
||||||
|
return new Item(item.getFeeCode(), item.getFeeTypeName(), item.getDefaultPrice());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Item(String code, String name, BigDecimal defaultPrice) { }
|
||||||
|
}
|
||||||
+6
-4
@@ -9,11 +9,12 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface ContainerCalendarSourceMapper {
|
public interface ContainerCalendarSourceMapper {
|
||||||
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
|
/** 按明确来源柜读取到港事实;目的港优先显示名称,未填名称时显示已保存代码。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
c.vessel_name vesselName,c.voyage_no voyageNo,
|
||||||
|
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
||||||
c.destination_warehouse_code destinationWarehouseCode,
|
c.destination_warehouse_code destinationWarehouseCode,
|
||||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||||
@@ -28,10 +29,11 @@ public interface ContainerCalendarSourceMapper {
|
|||||||
""")
|
""")
|
||||||
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
||||||
|
|
||||||
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
/** 选出到港、缺日期和可能还柜的有效出仓柜;目的港名称为空时展示代码,最终日期由服务复核。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
c.vessel_name vesselName,c.voyage_no voyageNo,
|
||||||
|
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
||||||
c.destination_warehouse_code destinationWarehouseCode,
|
c.destination_warehouse_code destinationWarehouseCode,
|
||||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
|||||||
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
|
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
|
||||||
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
|
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
|
||||||
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
||||||
|
operator_id BIGINT UNSIGNED NULL COMMENT '节点操作人账号ID;自动采集时为空',
|
||||||
|
operator_name VARCHAR(128) NULL COMMENT '节点操作人用户名快照;自动采集时为空',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ paths:
|
|||||||
containerNo: {type: string, nullable: true}
|
containerNo: {type: string, nullable: true}
|
||||||
vesselName: {type: string, nullable: true}
|
vesselName: {type: string, nullable: true}
|
||||||
voyageNo: {type: string, nullable: true}
|
voyageNo: {type: string, nullable: true}
|
||||||
destinationPortName: {type: string, nullable: true}
|
destinationPortName: {type: string, nullable: true, description: 目的港展示值,优先名称,名称为空时使用已保存的港口代码,两者均为空时返回null}
|
||||||
destinationWarehouseCode: {type: string, nullable: true}
|
destinationWarehouseCode: {type: string, nullable: true}
|
||||||
arrivalAt: {type: string, format: date-time, nullable: true}
|
arrivalAt: {type: string, format: date-time, nullable: true}
|
||||||
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ components:
|
|||||||
locationCode: {type: string, maxLength: 32, nullable: true}
|
locationCode: {type: string, maxLength: 32, nullable: true}
|
||||||
locationName: {type: string, maxLength: 128, nullable: true}
|
locationName: {type: string, maxLength: 128, nullable: true}
|
||||||
sourceReference: {type: string, maxLength: 500, nullable: true}
|
sourceReference: {type: string, maxLength: 500, nullable: true}
|
||||||
|
operatorName: {type: string, maxLength: 128, nullable: true, description: '节点操作人用户名快照;人工补录和确认出仓时记录,自动采集为空'}
|
||||||
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
|
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||||
createdAt: {type: string, format: date-time}
|
createdAt: {type: string, format: date-time}
|
||||||
ExceptionFact:
|
ExceptionFact:
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ paths:
|
|||||||
description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。
|
description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}}
|
||||||
responses: {'200': {description: 成功或业务失败}}
|
responses: {'200': {description: 成功或业务失败}}
|
||||||
|
/api/platform/v1/inbounds/{inboundId}/fees/preview:
|
||||||
|
post:
|
||||||
|
summary: 只读预览当前计费重量和体积
|
||||||
|
description: 不生成费用版本,不修改测量、费用、履约状态或财务记录;与正式计价共用价格匹配及柜级阶梯规则。FBA价格未命中时返回 priceMatched=false、计费量和空单价,供人工单价预览使用;其他错误明确返回。需要 firstleg:inbound:confirm-send-fee 或 firstleg:fba-pricing:calculate 权限。
|
||||||
|
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}]
|
||||||
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewRequest'}}}}
|
||||||
|
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewApiResponse'}}}}, '403': {description: 无权限}}
|
||||||
/api/platform/v1/inbounds/{inboundId}/fees/calculate:
|
/api/platform/v1/inbounds/{inboundId}/fees/calculate:
|
||||||
post:
|
post:
|
||||||
summary: 按审核通过测量快照试算 FBA 费用
|
summary: 按审核通过测量快照试算 FBA 费用
|
||||||
@@ -296,6 +303,32 @@ components:
|
|||||||
updatedAt: {type: string, format: date-time, nullable: true}
|
updatedAt: {type: string, format: date-time, nullable: true}
|
||||||
deleted: {type: boolean, nullable: true}
|
deleted: {type: boolean, nullable: true}
|
||||||
version: {type: integer, nullable: true}
|
version: {type: integer, nullable: true}
|
||||||
|
FeePreviewApiResponse:
|
||||||
|
type: object
|
||||||
|
required: [code, message, data]
|
||||||
|
properties:
|
||||||
|
code: {type: string}
|
||||||
|
message: {type: string}
|
||||||
|
data: {$ref: '#/components/schemas/FeePreviewResponse'}
|
||||||
|
requestId: {type: string, nullable: true}
|
||||||
|
traceId: {type: string, nullable: true}
|
||||||
|
timestamp: {type: string, format: date-time, nullable: true}
|
||||||
|
FeePreviewResponse:
|
||||||
|
type: object
|
||||||
|
required: [actualWeightKg, actualVolumeCbm, chargeableQuantity, chargeUnit, conversionFactor, roundingScale, roundingMode, unitPrice, additionalAmount, totalAmount, currency, priceMatched]
|
||||||
|
properties:
|
||||||
|
actualWeightKg: {type: number}
|
||||||
|
actualVolumeCbm: {type: number}
|
||||||
|
chargeableQuantity: {type: number}
|
||||||
|
chargeUnit: {type: string, enum: [KG, CBM]}
|
||||||
|
conversionFactor: {type: number}
|
||||||
|
roundingScale: {type: integer}
|
||||||
|
roundingMode: {type: string}
|
||||||
|
unitPrice: {type: number, nullable: true}
|
||||||
|
additionalAmount: {type: number}
|
||||||
|
totalAmount: {type: number, nullable: true, description: 按本次人工单价或参考单价计算并取整后的费用合计;两种单价都缺失时为空}
|
||||||
|
currency: {type: string}
|
||||||
|
priceMatched: {type: boolean}
|
||||||
FeeSnapshotApiResponse:
|
FeeSnapshotApiResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [code, message, data]
|
required: [code, message, data]
|
||||||
@@ -503,6 +536,15 @@ components:
|
|||||||
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0}
|
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0}
|
||||||
unitPrice: {type: number, exclusiveMinimum: true, minimum: 0}
|
unitPrice: {type: number, exclusiveMinimum: true, minimum: 0}
|
||||||
reason: {type: string, minLength: 2, maxLength: 500}
|
reason: {type: string, minLength: 2, maxLength: 500}
|
||||||
|
FeePreviewRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pricingAt: {type: string, format: date-time, nullable: true}
|
||||||
|
selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
|
||||||
|
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
|
||||||
|
weightSource: {type: string, maxLength: 32, nullable: true}
|
||||||
|
volumeSource: {type: string, maxLength: 32, nullable: true}
|
||||||
|
selectedUnitPrice: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 本次人工单价;为空时使用当前参考单价,仅用于只读金额预览}
|
||||||
CalculateRequest:
|
CalculateRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: 公共费用目录内部查询, version: 1.0.0 }
|
||||||
|
security: [{ internalToken: [] }]
|
||||||
|
paths:
|
||||||
|
/internal/firstleg/v1/fee-items:
|
||||||
|
get:
|
||||||
|
summary: 查询当前启用的国际海外仓费用项目
|
||||||
|
parameters:
|
||||||
|
- { in: query, name: direction, schema: { type: string, enum: [RECEIVABLE, PAYABLE], default: RECEIVABLE } }
|
||||||
|
responses:
|
||||||
|
'200': { description: '统一响应data为费用项目数组,项目含code、name、defaultPrice;defaultPrice币种固定CNY,可为空。' }
|
||||||
|
'403': { description: 内部凭证缺失、错误或服务未配置凭证 }
|
||||||
|
/internal/firstleg/v1/fee-items/{code}:
|
||||||
|
get:
|
||||||
|
summary: 校验录费使用的当前费用项目资格
|
||||||
|
parameters:
|
||||||
|
- { in: path, name: code, required: true, schema: { type: string } }
|
||||||
|
- { in: query, name: direction, required: true, schema: { type: string, enum: [RECEIVABLE, PAYABLE] } }
|
||||||
|
responses:
|
||||||
|
'200': { description: 统一响应data为项目code、name、defaultPrice }
|
||||||
|
'409': { description: 项目不存在、停用或不适用于所选方向 }
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
internalToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
|
||||||
@@ -2245,7 +2245,7 @@ components:
|
|||||||
outerWidthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱宽度修正;保存后参与联动计算}
|
outerWidthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱宽度修正;保存后参与联动计算}
|
||||||
outerHeightCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱高度修正;保存后参与联动计算}
|
outerHeightCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱高度修正;保存后参与联动计算}
|
||||||
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||||
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true}
|
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true, description: 明确选择的行算法优先;未指定时仅使用客户默认算法,ACTUAL 不调整修正后的实测尺寸,系数配置存在不代表启用 MULTIPLY}
|
||||||
remark: {type: string, maxLength: 500, nullable: true}
|
remark: {type: string, maxLength: 500, nullable: true}
|
||||||
InboundMeasurementDetailEnvelope:
|
InboundMeasurementDetailEnvelope:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class FbaPricingContractTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("确认和发送费用兼容新的组合权限与原有权限")
|
@DisplayName("确认和发送费用兼容新的组合权限与原有权限")
|
||||||
void allowsCombinedConfirmSendPermissionAndLegacyPermissions() {
|
void allowsCombinedConfirmSendPermissionAndLegacyPermissions() {
|
||||||
|
assertPricingAuthority("preview", "hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')");
|
||||||
assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
||||||
+ " 'firstleg:fba-pricing:confirm')");
|
+ " 'firstleg:fba-pricing:confirm')");
|
||||||
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
||||||
@@ -58,6 +59,8 @@ class FbaPricingContractTest {
|
|||||||
.getContentAsString(StandardCharsets.UTF_8);
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
assertThat(openapi)
|
assertThat(openapi)
|
||||||
.contains("/api/platform/v1/fba-pricing/customer-rules/versions:")
|
.contains("/api/platform/v1/fba-pricing/customer-rules/versions:")
|
||||||
|
.contains("/api/platform/v1/inbounds/{inboundId}/fees/preview:")
|
||||||
|
.contains("不生成费用版本")
|
||||||
.contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:")
|
.contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:")
|
||||||
.contains("/api/platform/v1/fba-pricing/carrier-rates/versions:")
|
.contains("/api/platform/v1/fba-pricing/carrier-rates/versions:")
|
||||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:")
|
.contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user