Compare commits
184
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a36b8c4c | ||
|
|
51291b4c27 | ||
|
|
0993dd446e | ||
|
|
fbdf58c23c | ||
|
|
40475388e1 | ||
|
|
22e8939f1f | ||
|
|
a01598bd21 | ||
|
|
0d8e64c42c | ||
|
|
8b56af2cae | ||
|
|
12617e35a0 | ||
|
|
46bc137e99 | ||
|
|
4165dc3403 | ||
|
|
6e451a82da | ||
|
|
78ecd2e039 | ||
|
|
3b1e38cf90 | ||
|
|
ba49035942 | ||
|
|
91e4fc33ff | ||
|
|
413e1e83e8 | ||
|
|
c3a6519ce4 | ||
|
|
2eb0b65ba5 | ||
|
|
b95281bacb | ||
|
|
5d6b2812cf | ||
|
|
1506576c67 | ||
|
|
7b5ef9ba64 | ||
|
|
21a9ce7d27 | ||
|
|
035e7e06af | ||
|
|
5825739b92 | ||
|
|
edd4b1cd38 | ||
|
|
51a93a69af | ||
|
|
872215367b | ||
|
|
ac013c265b | ||
|
|
e1bfcfc8f5 | ||
|
|
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 | ||
|
|
a8192f51fe | ||
|
|
20b2a7e058 | ||
|
|
7ef8411ea4 | ||
|
|
ab70c63988 | ||
|
|
ecc76d4f91 | ||
|
|
9c19980736 | ||
|
|
c38f1758c5 | ||
|
|
c0cd2c2183 | ||
|
|
3616e9db04 | ||
|
|
2b54e709f8 | ||
|
|
18ef338036 | ||
|
|
139f6b1932 | ||
|
|
b92f7fe991 | ||
|
|
f6c83125c9 | ||
|
|
f946285bcc | ||
|
|
5c5514436e | ||
|
|
a7f680d69d | ||
|
|
85c7cde040 | ||
|
|
7e1703bde9 | ||
|
|
6cd27658f7 | ||
|
|
d60a35b367 | ||
|
|
2dbf0cb248 | ||
|
|
eb82401afa | ||
|
|
0fd523e740 | ||
|
|
40d1b06312 | ||
|
|
f74861baea | ||
|
|
daa5d30d16 | ||
|
|
a16a85e14a | ||
|
|
f701456de1 | ||
|
|
ef0f691845 | ||
|
|
bfeeef130e | ||
|
|
9e0266b7c3 | ||
|
|
c385b7543b | ||
|
|
080ca600e6 | ||
|
|
86889d7e0e | ||
|
|
56f2853fb7 | ||
|
|
fc25507915 | ||
|
|
760bf8daa1 | ||
|
|
de96e47d74 | ||
|
|
5d71af1ac9 | ||
|
|
98939d9314 | ||
|
|
e60890e63e | ||
|
|
0c05c15e5a | ||
|
|
74f510a49b | ||
|
|
5783610154 | ||
|
|
8adaea75fa | ||
|
|
738b778edf | ||
|
|
2c3435adaf | ||
|
|
bbf8f2f792 | ||
|
|
5356a8c898 | ||
|
|
234216b3eb | ||
|
|
e2ad7857b4 | ||
|
|
632d35faaf | ||
|
|
538a63c635 | ||
|
|
6088ff10c2 | ||
|
|
6d04659ced | ||
|
|
044812cd90 | ||
|
|
ae361879e5 | ||
|
|
c442c9eaa7 | ||
|
|
1ea7b4ff12 | ||
|
|
0976b6ef0d | ||
|
|
0c6c55f6f1 | ||
|
|
4a0e86bd42 | ||
|
|
55436e3a0a | ||
|
|
c962f614e6 | ||
|
|
090591076f | ||
|
|
94595c810b | ||
|
|
6fe632f28e | ||
|
|
40a03978cd | ||
|
|
0af977bdef | ||
|
|
be79b90a4a | ||
|
|
337da6d67c | ||
|
|
b4ff441a70 | ||
|
|
9fb39604c6 | ||
|
|
6b2df287bd | ||
|
|
7a9ab27fd2 | ||
|
|
4d12b56cac | ||
|
|
91b6574d53 | ||
|
|
e5f9cdcf01 | ||
|
|
b072c0a884 | ||
|
|
f00d0e16cf | ||
|
|
af5cf1e3bd | ||
|
|
35a1b835fe | ||
|
|
9f6e43ef3f | ||
|
|
868557d79d | ||
|
|
137efc6809 | ||
|
|
34b90f311a | ||
|
|
46d359241f | ||
|
|
57fcf295b1 | ||
|
|
5950806924 | ||
|
|
4dc3d1752e | ||
|
|
f60f44793f | ||
|
|
d4eb0ef023 | ||
|
|
29bbd85ed6 | ||
|
|
f010dcc4e0 | ||
|
|
7151222836 | ||
|
|
4825f782a3 | ||
|
|
a7ca6b98f4 |
@@ -55,7 +55,7 @@
|
|||||||
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
|
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
|
||||||
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
|
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
|
||||||
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
|
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
|
||||||
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` |
|
| 海外业务费用来源跨区入池、人工费用与自动仓租 | `lastleg-service/`(`financefee/`、`storagerent/`)、`common-event-contract/`、`finance/` |
|
||||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
|
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
|
||||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
|
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
|
||||||
| 供应商、国内整柜库存与出入库 | `warehouse/` |
|
| 供应商、国内整柜库存与出入库 | `warehouse/` |
|
||||||
@@ -108,14 +108,14 @@
|
|||||||
|
|
||||||
## 数据库访问与中文代码注释(所有服务及公共模块强制)
|
## 数据库访问与中文代码注释(所有服务及公共模块强制)
|
||||||
|
|
||||||
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
|
- 本节适用于全部后端服务和公共模块;新增代码遵守下列规范,存量分层仅在影响本次正确性或用户明确要求时整改,不因局部修改迁移整条链路;以上为目标规范,不代表存量均已达标。
|
||||||
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
|
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
|
||||||
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
|
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
|
||||||
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
|
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
|
||||||
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
|
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
|
||||||
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
|
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
|
||||||
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
|
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
|
||||||
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
|
- 本次新增或修改的手写方法及构造器须有简短中文业务注释,通常 1—3 行;接口与 Mapper 方法优先用中文 Javadoc,覆盖方法同样适用,关键规则就地说明。不补齐未修改或自动生成方法的注释。
|
||||||
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
|
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
|
||||||
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
|
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
|
||||||
|
|
||||||
@@ -132,7 +132,11 @@
|
|||||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
|
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
|
||||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||||
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。
|
||||||
|
- 局部逻辑使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及必要边界即可交付,不因收尾再跑 `Module`。Java 签名变化须覆盖生产与测试源码编译;定向测试已编译时不重复跑 `-Mode Compile`。只有影响扩及服务内多个行为且定向覆盖不足才选 `Module`,先说明原因。跨服务 DTO、接口或事件变化显式列入生产者和实际消费者;`-am` 仅补上游构建依赖,不自动包含下游或 HTTP/事件消费者。
|
||||||
|
- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。
|
||||||
|
- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 POM、依赖或测试配置改动先按实际影响选编译或测试,不一律全仓。入口为 `scripts/backend-full-regression.ps1`,CI 全量保留。普通提交、单服务任务或对话收尾不触发;有效结果复用,输入变化只补跑受影响范围。
|
||||||
|
- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||||
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
||||||
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||||
|
|||||||
Vendored
+35
-2
@@ -67,6 +67,8 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
|
|||||||
List<String> failedServices = []
|
List<String> failedServices = []
|
||||||
if (state.startsWith('RESULTS')) {
|
if (state.startsWith('RESULTS')) {
|
||||||
String summary = state.substring('RESULTS'.length()).trim()
|
String summary = state.substring('RESULTS'.length()).trim()
|
||||||
|
int metadataIndex = summary.indexOf(' | ')
|
||||||
|
if (metadataIndex >= 0) summary = summary.substring(0, metadataIndex).trim()
|
||||||
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||||
int separator = item.indexOf('=')
|
int separator = item.indexOf('=')
|
||||||
if (separator > 0) {
|
if (separator > 0) {
|
||||||
@@ -103,6 +105,25 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试范围必须相对成功门禁,而非失败/取消轮次;否则补跑可能遗漏前次未通过的变更。
|
||||||
|
def previousSuccessfulTestCommit(def previousBuild, List<String> requiredServices) {
|
||||||
|
def candidate = previousBuild
|
||||||
|
int inspected = 0
|
||||||
|
while (candidate != null && inspected < 30) {
|
||||||
|
String description = candidate.description ?: ''
|
||||||
|
String marker = ' | TESTS=PASSED '
|
||||||
|
int markerIndex = description.lastIndexOf(marker)
|
||||||
|
if (candidate.result == 'SUCCESS' && markerIndex >= 0) {
|
||||||
|
List metadata = description.substring(markerIndex + marker.length()).trim().split(' ')
|
||||||
|
if (metadata.size() == 2 && metadata[0] ==~ /[0-9a-fA-F]{7,40}/ &&
|
||||||
|
requiredServices.every { metadata[1].split(',').contains(it) }) return metadata[0]
|
||||||
|
}
|
||||||
|
candidate = candidate.previousBuild
|
||||||
|
inspected++
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
def environmentChoicesForJob(String jobBaseName) {
|
def environmentChoicesForJob(String jobBaseName) {
|
||||||
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
||||||
}
|
}
|
||||||
@@ -161,7 +182,9 @@ pipeline {
|
|||||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
||||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
|
choice(name: 'TEST_SCOPE', choices: ['AUTO', 'MODULE'], description: 'AUTO 对已支持的局部变更执行关联测试;公共/未知变更自动完整模块回归;MODULE 显式完整模块回归')
|
||||||
|
// 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。
|
||||||
|
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image')
|
||||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||||
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
||||||
}
|
}
|
||||||
@@ -268,6 +291,7 @@ pipeline {
|
|||||||
selected = selected.unique()
|
selected = selected.unique()
|
||||||
|
|
||||||
env.SELECTED_SERVICES = selected.join(',')
|
env.SELECTED_SERVICES = selected.join(',')
|
||||||
|
env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected)
|
||||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||||
@@ -334,16 +358,22 @@ pipeline {
|
|||||||
.findAll { it }
|
.findAll { it }
|
||||||
.join(',')
|
.join(',')
|
||||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||||
|
String testPlan = "${pwd(tmp: true)}/ci-test-plan.json"
|
||||||
lock(resource: 'firstleg-test-maven') {
|
lock(resource: 'firstleg-test-maven') {
|
||||||
timeout(time: 50, unit: 'MINUTES') {
|
timeout(time: 50, unit: 'MINUTES') {
|
||||||
sh """
|
sh """
|
||||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||||
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
node deploy/scripts/ci-test-scope.mjs plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}'
|
||||||
|
node deploy/scripts/ci-test-scope.mjs checks --plan '${testPlan}'
|
||||||
|
test_arguments=\"\$(node deploy/scripts/ci-test-scope.mjs args --plan '${testPlan}')\"
|
||||||
|
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test \$test_arguments
|
||||||
|
node deploy/scripts/ci-test-scope.mjs verify --plan '${testPlan}'
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||||
|
env.TEST_GATE_PASSED = 'true'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
post {
|
post {
|
||||||
@@ -400,6 +430,9 @@ pipeline {
|
|||||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||||
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
||||||
: "${env.IMAGE_TAG} | ${summary}"
|
: "${env.IMAGE_TAG} | ${summary}"
|
||||||
|
if (env.TEST_GATE_PASSED == 'true') {
|
||||||
|
currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}"
|
||||||
|
}
|
||||||
if (failed) {
|
if (failed) {
|
||||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -49,7 +49,8 @@ pipeline {
|
|||||||
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
|
// 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。
|
||||||
|
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写')
|
||||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,16 @@
|
|||||||
## 本地构建
|
## 本地构建
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
mvn -DskipTests validate
|
# 日常按受影响模块检查;跨服务变更显式列入消费者。
|
||||||
node --test scripts/java-topology-contract.test.mjs
|
.\scripts\backend-check.ps1 -Modules order -Mode Compile
|
||||||
mvn test
|
.\scripts\backend-check.ps1 -Modules order
|
||||||
|
# 根构建/拓扑变化等全量触发条件见 AGENTS.md。
|
||||||
|
.\scripts\local-check.ps1 -Command @('node', '--test', 'scripts/java-topology-contract.test.mjs')
|
||||||
|
.\scripts\backend-full-regression.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
开发中可通过 `backend-check.ps1 -Mode Focused -Modules <模块> -Tests <精确测试类名>` 快速反馈;参数、DryRun 和结果边界见 [后端分层检查](scripts/README.md#后端分层检查)。单服务修改收尾执行模块回归,不默认执行上面的全仓命令。
|
||||||
|
|
||||||
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
||||||
|
|
||||||
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.baiyue.common.core.number;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.function.ToLongFunction;
|
||||||
|
|
||||||
|
/** 统一业务单号格式;序列仍由各服务自己的 Repository/Mapper 分配。 */
|
||||||
|
public final class DailyDocumentNumber {
|
||||||
|
public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
|
||||||
|
/** 工具类不允许实例化。 */
|
||||||
|
private DailyDocumentNumber() {}
|
||||||
|
|
||||||
|
/** 先固定北京时间业务日,再使用前缀和日期分配跨客户、跨仓库日流水。 */
|
||||||
|
public static String next(String prefix, Clock clock, ToLongFunction<String> sequences) {
|
||||||
|
requirePrefix(prefix);
|
||||||
|
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
|
||||||
|
String dateText = date.format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||||
|
long value = sequences.applyAsLong(prefix + ":" + dateText);
|
||||||
|
return format(prefix, date, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 严格四位流水,不允许溢出后产生五位号码或退回随机编号。 */
|
||||||
|
public static String format(String prefix, LocalDate date, long value) {
|
||||||
|
requirePrefix(prefix);
|
||||||
|
if (value < 1 || value > 9999) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据四位流水号已用尽或分配失败");
|
||||||
|
}
|
||||||
|
return prefix + date.format(DateTimeFormatter.BASIC_ISO_DATE)
|
||||||
|
+ String.format(Locale.ROOT, "%04d", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务类型只能使用两至四位大写字母。 */
|
||||||
|
private static void requirePrefix(String prefix) {
|
||||||
|
if (prefix == null || !prefix.matches("[A-Z]{2,4}")) {
|
||||||
|
throw new IllegalArgumentException("业务单据前缀必须为2至4位大写字母");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.baiyue.common.core.number;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class DailyDocumentNumberTest {
|
||||||
|
/** 午夜前后取号使用北京时间日期,与主机和 Clock 的时区无关。 */
|
||||||
|
@Test void rollsAtShanghaiMidnightAndKeepsPrefixesIndependent() {
|
||||||
|
var values = new HashMap<String, Long>();
|
||||||
|
java.util.function.ToLongFunction<String> allocate = key -> values.merge(key, 1L, Long::sum);
|
||||||
|
var before = Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"), ZoneOffset.UTC);
|
||||||
|
var after = Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"), ZoneId.of("America/Los_Angeles"));
|
||||||
|
assertThat(DailyDocumentNumber.next("FL", before, allocate)).isEqualTo("FL202610190001");
|
||||||
|
assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200001");
|
||||||
|
assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200002");
|
||||||
|
assertThat(DailyDocumentNumber.next("ZGRK", after, allocate)).isEqualTo("ZGRK202610200001");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字符集和流水上限为硬边界,未知输入不能生成近似号码。 */
|
||||||
|
@Test void rejectsInvalidPrefixAndSequence() {
|
||||||
|
for (String prefix : new String[]{"F", "ABCDE", "Fl", "F1", " FL", ""}) {
|
||||||
|
assertThatThrownBy(() -> DailyDocumentNumber.format(prefix, LocalDate.of(2026, 10, 20), 1))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
for (long value : new long[]{-1, 0, 10000}) {
|
||||||
|
assertThatThrownBy(() -> DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), value))
|
||||||
|
.isInstanceOf(BizException.class);
|
||||||
|
}
|
||||||
|
assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 9999)).isEqualTo("FL202610209999");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局 Locale 不得让数字变为本地化字符。 */
|
||||||
|
@Test void alwaysUsesAsciiDigits() {
|
||||||
|
Locale old = Locale.getDefault();
|
||||||
|
try {
|
||||||
|
Locale.setDefault(Locale.forLanguageTag("ar"));
|
||||||
|
assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 1)).isEqualTo("FL202610200001");
|
||||||
|
} finally { Locale.setDefault(old); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,11 @@ package com.baiyue.common.event;
|
|||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
/** Canonical physical container number used by cross-region business facts. */
|
/** 跨服务业务事实使用的真实集装箱号,只规范化空白和大小写,不校验 ISO 格式。 */
|
||||||
public final class ContainerNumbers {
|
public final class ContainerNumbers {
|
||||||
private static final int[] LETTER_VALUES = {
|
|
||||||
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
|
||||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
|
||||||
};
|
|
||||||
|
|
||||||
private ContainerNumbers() { }
|
private ContainerNumbers() { }
|
||||||
|
|
||||||
|
/** 柜号必须非空;统一移除格式空白并转大写,保留业务录入值。 */
|
||||||
public static String normalizeRequired(String value) {
|
public static String normalizeRequired(String value) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
throw new IllegalArgumentException("containerNo is required");
|
throw new IllegalArgumentException("containerNo is required");
|
||||||
@@ -19,21 +15,13 @@ public final class ContainerNumbers {
|
|||||||
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||||
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||||
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||||
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
if (normalized.isEmpty()) {
|
||||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
throw new IllegalArgumentException("containerNo is required");
|
||||||
}
|
|
||||||
int sum = 0;
|
|
||||||
for (int index = 0; index < 10; index++) {
|
|
||||||
char current = normalized.charAt(index);
|
|
||||||
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
|
|
||||||
sum += number * (1 << index);
|
|
||||||
}
|
|
||||||
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
|
|
||||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
|
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 冻结事实必须已规范化,避免生产与消费端的快照或哈希发生漂移。 */
|
||||||
public static String requireCanonical(String value) {
|
public static String requireCanonical(String value) {
|
||||||
String normalized = normalizeRequired(value);
|
String normalized = normalizeRequired(value);
|
||||||
if (!normalized.equals(value)) {
|
if (!normalized.equals(value)) {
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
+27
-6
@@ -1,20 +1,41 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class ContainerNumbersTest {
|
class ContainerNumbersTest {
|
||||||
|
/** 规范化仍保持确定性,事件只能接受已冻结的规范值。 */
|
||||||
@Test
|
@Test
|
||||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactValue() {
|
||||||
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||||
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
|
||||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||||
.hasMessageContaining("normalized");
|
.hasMessageContaining("normalized");
|
||||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(" JZXH10231043 "))
|
||||||
.hasMessageContaining("check digit");
|
.hasMessageContaining("normalized");
|
||||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
}
|
||||||
.hasMessageContaining("ISO 6346");
|
|
||||||
|
/** 非 ISO 柜号可以作为事件事实,不在下游重新阻断。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"JZXH10231043", "ZCSU7881971", "MSCA6639870", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||||
|
void acceptsNonIsoNumber(String containerNo) {
|
||||||
|
assertThat(ContainerNumbers.normalizeRequired(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " "))
|
||||||
|
.isEqualTo(containerNo);
|
||||||
|
assertThat(ContainerNumbers.requireCanonical(containerNo)).isEqualTo(containerNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化前后均不得将空白柜号作为业务事实。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@NullAndEmptySource
|
||||||
|
@ValueSource(strings = {" ", "\u00a0\u3000"})
|
||||||
|
void rejectsMissingNumber(String containerNo) {
|
||||||
|
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired(containerNo))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
|
||||||
|
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(containerNo))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,8 +40,14 @@ public class GlobalExceptionHandler {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private MeterRegistry meterRegistry;
|
private MeterRegistry meterRegistry;
|
||||||
|
|
||||||
|
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
|
||||||
@ExceptionHandler(BizException.class)
|
@ExceptionHandler(BizException.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
||||||
|
if (exception.getResultCode().httpStatus().is5xxServerError()) {
|
||||||
|
log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}",
|
||||||
|
request.getMethod(), request.getRequestURI(), exception.getResultCode().code(),
|
||||||
|
CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception);
|
||||||
|
}
|
||||||
return failure(exception.getResultCode(), exception.getMessage(), request);
|
return failure(exception.getResultCode(), exception.getMessage(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException;
|
|||||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput;
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
import org.springframework.core.MethodParameter;
|
import org.springframework.core.MethodParameter;
|
||||||
import org.springframework.validation.BeanPropertyBindingResult;
|
import org.springframework.validation.BeanPropertyBindingResult;
|
||||||
@@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */
|
||||||
|
@Test
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
|
void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts");
|
||||||
|
request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request");
|
||||||
|
request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace");
|
||||||
|
request.setQueryString("keyword=private-query");
|
||||||
|
request.addHeader("X-Internal-Service-Token", "private-service-token");
|
||||||
|
BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用");
|
||||||
|
exception.initCause(new IllegalStateException("缺少尾程来源"));
|
||||||
|
|
||||||
|
var response = handler.handleBusiness(exception, request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody().code()).isEqualTo("S0503");
|
||||||
|
assertThat(response.getBody().message()).isEqualTo("统计暂不可用");
|
||||||
|
assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts",
|
||||||
|
"S0503", "status-count-request", "status-count-trace", "缺少尾程来源")
|
||||||
|
.doesNotContain("private-query", "private-service-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预期的业务冲突不应升级为系统错误日志。 */
|
||||||
|
@Test
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
|
void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) {
|
||||||
|
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"),
|
||||||
|
new MockHttpServletRequest());
|
||||||
|
assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ spring:
|
|||||||
password: ${FINANCE_DB_PASSWORD}
|
password: ${FINANCE_DB_PASSWORD}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
|
# TEST 流程验收复用 Client 的示例图片,图片标注不可付款且不含真实收款信息。
|
||||||
|
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
|
||||||
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
|
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
|
||||||
baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
|
baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
|
||||||
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
|
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'
|
||||||
|
import { resolve, relative, basename } from 'node:path'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const moduleName = 'lastleg-service'
|
||||||
|
const testClass = /(?:^Test.+|.+(?:Test|Tests|TestCase))\.java$/
|
||||||
|
const words = source => new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
|
||||||
|
const tables = source => new Set(source.match(/\bll_[a-z0-9_]+\b/g) || [])
|
||||||
|
const writtenTables = source => new Set([...source.matchAll(/\b(?:UPDATE|INSERT\s+(?:IGNORE\s+)?INTO|DELETE\s+FROM)\s+(ll_[a-z0-9_]+)/gi)].map(match => match[1]))
|
||||||
|
|
||||||
|
// 按完整注解比较 SQL,避免只改 SELECT 时把同一 Mapper 内未改的全部写语句扩大为全域回归。
|
||||||
|
function writeStatements(source) {
|
||||||
|
const result = []
|
||||||
|
for (const match of source.matchAll(/@(?:Insert|Update|Delete)\s*\(/g)) {
|
||||||
|
let depth = 1, quote = '', index = match.index + match[0].length
|
||||||
|
for (; index < source.length && depth; index++) {
|
||||||
|
if (quote) {
|
||||||
|
if (source.startsWith(quote, index)) { index += quote.length - 1; quote = '' }
|
||||||
|
else if (source[index] === '\\' && quote.length === 1) index++
|
||||||
|
} else if (source.startsWith('"""', index)) { quote = '"""'; index += 2 }
|
||||||
|
else if (source[index] === '"' || source[index] === "'") quote = source[index]
|
||||||
|
else if (source[index] === '(') depth++
|
||||||
|
else if (source[index] === ')') depth--
|
||||||
|
}
|
||||||
|
if (depth) throw new Error('Cannot parse SQL annotation; use MODULE test scope')
|
||||||
|
result.push(source.slice(match.index, index))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedWrites(node) {
|
||||||
|
if (node.previousSource === undefined) return writtenTables(node.source)
|
||||||
|
const before = writeStatements(node.previousSource), after = writeStatements(node.source)
|
||||||
|
const changed = [...before.filter(sql => !after.includes(sql)), ...after.filter(sql => !before.includes(sql))]
|
||||||
|
const oldRest = before.reduce((text, sql) => text.replace(sql, ''), node.previousSource)
|
||||||
|
const newRest = after.reduce((text, sql) => text.replace(sql, ''), node.source)
|
||||||
|
if (oldRest !== newRest) changed.push(oldRest, newRest)
|
||||||
|
return writtenTables(changed.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宁可扩大范围,不用文件名猜测唯一测试;未识别的输入保留完整模块门禁。 */
|
||||||
|
export function planTests({ modules, mode = 'AUTO', changes = [], sources = [], baseline = '' }) {
|
||||||
|
const architectureScript = `${moduleName}/scripts/analyze-lastleg-dependencies`
|
||||||
|
const staticChecks = changes.some(path => path.startsWith(architectureScript))
|
||||||
|
? [`${architectureScript}.test.mjs`] : []
|
||||||
|
const full = reason => ({ scope: 'MODULE', reason, modules, baseline, tests: [], staticChecks })
|
||||||
|
if (mode !== 'AUTO' && mode !== 'MODULE') throw new Error('TEST_SCOPE must be AUTO or MODULE')
|
||||||
|
if (mode === 'MODULE') return full('显式完整模块回归')
|
||||||
|
if (!baseline) return full('没有成功测试基线')
|
||||||
|
if (modules.length !== 1 || modules[0] !== moduleName) return full('多服务或尚未核对的服务采用完整模块回归')
|
||||||
|
const prefix = `${moduleName}/`
|
||||||
|
const relevant = changes.filter(path => path.startsWith(prefix))
|
||||||
|
const shared = changes.some(path => path !== 'scripts/agents-index-contract.ps1' && !path.startsWith(prefix)
|
||||||
|
&& /^(?:common-[^/]+\/|platform-bom\/|pom\.xml$|Jenkinsfile|deploy\/|config\/|scripts\/.*\.(?:mjs|sh|ps1)$)/.test(path))
|
||||||
|
if (shared) return full('公共能力、发布或测试基础设施变更')
|
||||||
|
if (!relevant.length) return full('补跑服务没有可验证的业务差异')
|
||||||
|
const java = `${prefix}src/main/java/com/baiyue/lastleg/`
|
||||||
|
const supported = path => path.startsWith(`${prefix}src/test/java/`)
|
||||||
|
|| path.startsWith(`${prefix}src/main/resources/openapi/`)
|
||||||
|
|| path.startsWith(architectureScript)
|
||||||
|
|| (path.startsWith(java) && path.slice(java.length).includes('/')
|
||||||
|
&& !/^(?:config|security|entity|dto|model|mapper|repository|service|client)\//.test(path.slice(java.length)))
|
||||||
|
|| /\.(?:md)$/.test(path)
|
||||||
|
if (relevant.some(path => !supported(path))) return full('共享模型、配置、SQL、资源或未识别路径变更')
|
||||||
|
|
||||||
|
const nodes = sources.map(({ path, source, previousSource }) => ({ path, source, previousSource,
|
||||||
|
symbol: basename(path, '.java'), tokens: words(source), tables: tables(source),
|
||||||
|
test: path.startsWith(`${prefix}src/test/java/`),
|
||||||
|
name: `${source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''}.${basename(path, '.java')}` }))
|
||||||
|
const byPath = new Map(nodes.map(node => [node.path, node]))
|
||||||
|
const bySymbol = new Map()
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!bySymbol.has(node.symbol)) bySymbol.set(node.symbol, [])
|
||||||
|
bySymbol.get(node.symbol).push(node)
|
||||||
|
}
|
||||||
|
const reverse = new Map(nodes.map(node => [node.path, new Set()]))
|
||||||
|
for (const node of nodes) {
|
||||||
|
// 包含反射字符串、静态 import、同包引用及测试夹具;重名类全部纳入。
|
||||||
|
for (const token of node.tokens) for (const dependency of bySymbol.get(token) || []) {
|
||||||
|
if (dependency !== node) reverse.get(dependency.path).add(node.path)
|
||||||
|
}
|
||||||
|
// 实现变化也要覆盖只依赖接口/基类的调用者,不能仅沿显式 import 反查。
|
||||||
|
for (const match of node.source.matchAll(/\b(?:implements|extends)\s+([^{};]+)/g)) {
|
||||||
|
for (const token of words(match[1])) for (const contract of bySymbol.get(token) || []) {
|
||||||
|
if (contract !== node) reverse.get(node.path).add(contract.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seeds = new Set()
|
||||||
|
const changedTables = new Set()
|
||||||
|
for (const path of relevant.filter(path => path.endsWith('.java'))) {
|
||||||
|
const node = byPath.get(path)
|
||||||
|
if (node) {
|
||||||
|
seeds.add(path)
|
||||||
|
// 读查询变化不改变其他查询的数据;潜在写入变化则纳入同表读写消费者。
|
||||||
|
if (!node.test) for (const table of changedWrites(node)) changedTables.add(table)
|
||||||
|
} else {
|
||||||
|
// 删除/移动旧类型时,仍按旧类名查现存消费者。
|
||||||
|
const symbol = basename(path, '.java')
|
||||||
|
const consumers = nodes.filter(consumer => consumer.tokens.has(symbol))
|
||||||
|
if (!consumers.length) return full('删除类型缺少可追踪的现存消费者')
|
||||||
|
for (const consumer of consumers) seeds.add(consumer.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!seeds.size) return full('没有可追踪的 Java 行为差异')
|
||||||
|
for (const node of nodes) if ([...node.tables].some(table => changedTables.has(table))) seeds.add(node.path)
|
||||||
|
const affected = new Set(seeds)
|
||||||
|
const queue = [...seeds]
|
||||||
|
for (let index = 0; index < queue.length; index++) {
|
||||||
|
for (const consumer of reverse.get(queue[index]) || []) if (!affected.has(consumer)) {
|
||||||
|
affected.add(consumer); queue.push(consumer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allTests = nodes.filter(node => node.test && testClass.test(basename(node.path))
|
||||||
|
&& /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/.test(node.source))
|
||||||
|
const contractChange = relevant.some(path => path.startsWith(`${prefix}src/main/resources/openapi/`))
|
||||||
|
const candidates = allTests.filter(node => affected.has(node.path)
|
||||||
|
|| /(?:Security|Isolation|MapperRegistrationArchitecture|QueryBinding)/.test(node.symbol)
|
||||||
|
|| (contractChange && /(?:Contract|OpenApi|Schema|Architecture|Permission)/.test(node.symbol)))
|
||||||
|
// 显式依赖真实环境的类仍由 MODULE/专项授权入口负责,不能把它们默认跳过算作执行成功。
|
||||||
|
const optIn = node => /@(?:EnabledIf(?:SystemProperty|EnvironmentVariable)|Disabled(?:IfSystemProperty|IfEnvironmentVariable)?\b)/
|
||||||
|
.test(node.source.slice(0, node.source.indexOf(`class ${node.symbol}`)))
|
||||||
|
const tests = candidates.filter(node => !optIn(node))
|
||||||
|
if (!tests.some(node => affected.has(node.path))) return full('没有匹配到行为测试,拒绝零测试通过')
|
||||||
|
if (tests.some(node => !/^com\.[\w.$]+$/.test(node.name))) return full('测试类名无法安全传入 Maven')
|
||||||
|
return { scope: 'AFFECTED', reason: 'Java 反向引用、接口实现、测试夹具、共享 SQL 表和相关静态合同',
|
||||||
|
modules, baseline, tests: tests.map(node => node.name).sort(), totalTests: allTests.length, staticChecks,
|
||||||
|
optInTests: candidates.filter(optIn).map(node => node.name).sort() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(root) {
|
||||||
|
const files = []
|
||||||
|
for (const item of readdirSync(root, { withFileTypes: true })) {
|
||||||
|
const path = resolve(root, item.name)
|
||||||
|
if (item.isDirectory()) files.push(...walk(path))
|
||||||
|
else if (item.isFile() && item.name.endsWith('.java')) files.push(path)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个所选类必须有本轮实际执行的报告;允许上游零匹配不能变成整个门禁零测试。 */
|
||||||
|
export function verifyReports(plan, reports) {
|
||||||
|
if (plan.scope !== 'AFFECTED') return
|
||||||
|
for (const name of plan.tests) {
|
||||||
|
const matches = reports.filter(report => report.name === name || report.name.startsWith(`${name}$`))
|
||||||
|
if (!matches.length || matches.some(report => report.failures || report.errors
|
||||||
|
|| ['tests', 'skipped', 'failures', 'errors'].some(key => !Number.isInteger(report[key]) || report[key] < 0)
|
||||||
|
|| report.skipped > report.tests)
|
||||||
|
|| matches.reduce((sum, report) => sum + report.tests - report.skipped, 0) <= 0) {
|
||||||
|
throw new Error(`Missing, skipped or failed fresh test report: ${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(args) {
|
||||||
|
const [action, ...options] = args
|
||||||
|
const values = {}
|
||||||
|
for (let i = 0; i < options.length; i += 2) {
|
||||||
|
if (!options[i].startsWith('--') || options[i + 1] === undefined) throw new Error('Expected --name value')
|
||||||
|
values[options[i].slice(2)] = options[i + 1]
|
||||||
|
}
|
||||||
|
const root = process.cwd()
|
||||||
|
const planFile = values.plan
|
||||||
|
if (!planFile) throw new Error('--plan is required')
|
||||||
|
if (action === 'plan') {
|
||||||
|
const modules = (values.modules || '').split(',')
|
||||||
|
if (modules.some(module => !/^[a-z][a-z0-9-]+$/.test(module))) throw new Error('Invalid modules')
|
||||||
|
let baseline = values.base || ''
|
||||||
|
let changes = []
|
||||||
|
if (baseline) {
|
||||||
|
if (!/^[0-9a-f]{7,40}$/i.test(baseline)) throw new Error('Invalid baseline commit')
|
||||||
|
try {
|
||||||
|
execFileSync('git', ['merge-base', '--is-ancestor', baseline, 'HEAD'], { stdio: 'pipe' })
|
||||||
|
changes = execFileSync('git', ['diff', '--name-only', '-z', baseline, 'HEAD'], { encoding: 'utf8' }).split('\0').filter(Boolean)
|
||||||
|
} catch { baseline = '' }
|
||||||
|
}
|
||||||
|
const sources = modules.length === 1 && modules[0] === moduleName
|
||||||
|
? ['main', 'test'].flatMap(set => walk(resolve(root, moduleName, 'src', set, 'java')))
|
||||||
|
.map(path => {
|
||||||
|
const key = relative(root, path).replaceAll('\\', '/')
|
||||||
|
const entry = { path: key, source: readFileSync(path, 'utf8') }
|
||||||
|
if (baseline && changes.includes(key)) {
|
||||||
|
try { entry.previousSource = execFileSync('git', ['show', `${baseline}:${key}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) }
|
||||||
|
catch { entry.previousSource = '' }
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}) : []
|
||||||
|
const plan = { ...planTests({ modules, mode: values.mode || 'AUTO', baseline, changes, sources }), startedAt: Date.now() }
|
||||||
|
writeFileSync(planFile, JSON.stringify(plan))
|
||||||
|
console.log(`CI test scope: ${plan.scope}; ${plan.reason}`)
|
||||||
|
if (plan.scope === 'AFFECTED') console.log(`Selected ${plan.tests.length}/${plan.totalTests} test classes:\n${plan.tests.join('\n')}`)
|
||||||
|
if (plan.optInTests?.length) console.log(`Explicit environment tests remain opt-in (not counted as passed): ${plan.optInTests.join(',')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const plan = JSON.parse(readFileSync(planFile, 'utf8'))
|
||||||
|
if (action === 'args') {
|
||||||
|
if (plan.scope === 'AFFECTED') process.stdout.write(`-Dtest=${plan.tests.join(',')} -Dsurefire.failIfNoSpecifiedTests=false -Dci.sql-semantic.skip=true`)
|
||||||
|
} else if (action === 'checks') {
|
||||||
|
for (const check of plan.staticChecks || []) {
|
||||||
|
if (check !== `${moduleName}/scripts/analyze-lastleg-dependencies.test.mjs`) throw new Error('Unsupported static check')
|
||||||
|
execFileSync(process.execPath, ['--test', '--test-concurrency=2', check], { stdio: 'inherit' })
|
||||||
|
}
|
||||||
|
} else if (action === 'verify') {
|
||||||
|
const reports = []
|
||||||
|
if (plan.scope === 'AFFECTED') for (const module of plan.modules) {
|
||||||
|
const directory = resolve(root, module, 'target/surefire-reports')
|
||||||
|
for (const filename of readdirSync(directory)) {
|
||||||
|
if (!/^TEST-.*\.xml$/.test(filename)) continue
|
||||||
|
const path = resolve(directory, filename)
|
||||||
|
if (statSync(path).mtimeMs < plan.startedAt - 2000) continue
|
||||||
|
const xml = readFileSync(path, 'utf8').match(/<testsuite\b([^>]+)>/)?.[1]
|
||||||
|
if (!xml) continue
|
||||||
|
const attrs = Object.fromEntries([...xml.matchAll(/([\w-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]))
|
||||||
|
reports.push({ name: attrs.name || '', ...Object.fromEntries(['tests', 'failures', 'errors', 'skipped'].map(key => [key, Number(attrs[key] || 0)])) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verifyReports(plan, reports)
|
||||||
|
console.log(`CI test reports verified: ${plan.scope}`)
|
||||||
|
} else throw new Error(`Unknown action: ${action}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 }
|
||||||
|
}
|
||||||
@@ -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)。
|
||||||
File diff suppressed because it is too large
Load Diff
+63
-6
@@ -1,5 +1,45 @@
|
|||||||
# 单测试环境数据库空库初始化
|
# 单测试环境数据库空库初始化
|
||||||
|
|
||||||
|
`20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。
|
||||||
|
|
||||||
|
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
|
||||||
|
|
||||||
|
散板收货闭环:`migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql` 为已有 Lastleg 库增加入仓方式、收货仓、联系人、预计到仓、物流号及转运关联字段;新库使用同步后的海外统一基线。IAM 使用 `migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql` 补齐“散板需求”菜单及“加入散板货物”按钮,并为既有平台管理员补取货排车创建权限;权限源同步于 `iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql` 和国内统一基线。两项已在本机应用并通过两场景HTTP验收,未据此认定TEST或生产已部署;已有库不可重放空库基线,V37执行前须核对九个新增字段尚不存在。
|
||||||
|
|
||||||
|
`migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql` 修复海外人工费用资金重试报 S0500:仅将 `baiyue_lastleg.ll_finance_fee_command.action` 扩为 `VARCHAR(32)`,容纳 20 字符的 `MANUAL_FUNDING_RETRY`,保留中文备注、非空及原幂等唯一键。Lastleg 源基线和海外空库基线同步。已有环境先核对目标库及列定义,经授权执行 V35、回查列长度和记录总数,再通过原费用重试入口验证;可重复执行,不修改费用、审核、余额或资金记录,不重放空库基线。DDL 隐式提交,遇错停止;本次仅准备修复文件,尚未执行测试环境 SQL 或部署,文件存在不表示已经执行。
|
||||||
|
|
||||||
|
`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。
|
||||||
|
|
||||||
|
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
|
||||||
|
|
||||||
|
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
|
||||||
|
|
||||||
|
`finance-reconciliation-line-uniqueness-repair.sql` 修正 TEST 作废客户对账单后原费用无法重新生成对账单的问题:以一条原子 ALTER 将 `baiyue_finance.fin_bill_line` 的费用版本全历史唯一键改为单内唯一键,并保留费用来源普通索引。作废明细和金额快照完整保留;有效费用占用继续由 Finance 生成时的行锁、AVAILABLE → LOCKED 状态校验及作废单重开校验保证。模块基线与统一空库基线同步。先核对上述服务校验及目标表结构,经单独授权执行,再核对结构和记录总数;遇错停止,结果不明确先回查,不盲目重放,不更新业务数据或重放空库基线。脚本存在不表示已执行。
|
||||||
|
|
||||||
|
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
|
||||||
|
|
||||||
|
`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。
|
||||||
|
|
||||||
|
`overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。
|
||||||
|
|
||||||
|
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
|
||||||
|
|
||||||
|
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
||||||
|
|
||||||
|
货柜关联已放舱订舱时不再限制 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` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
|
||||||
|
|
||||||
排车单号使用`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,准确单量以主单统计为准。
|
||||||
|
|
||||||
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
|
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
|
||||||
@@ -14,7 +54,7 @@
|
|||||||
|
|
||||||
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
|
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
|
||||||
|
|
||||||
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
|
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_0900_ai_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
|
||||||
|
|
||||||
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
|
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
|
||||||
|
|
||||||
@@ -34,11 +74,11 @@
|
|||||||
|
|
||||||
`reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`,并首次创建 `ll_fba_appointment_command` 保存同事务幂等结果与历史。执行任何DROP前必须确认命令表尚不存在;若已存在,先核对上次执行结果,本脚本不清空命令历史、不支持盲目重试。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接及两张表的具体重置授权;不得作为自动启动脚本或用于生产。主单区分计划日期/时段、系统单号、外部确认号和确认UTC/IANA时区;明细按稳定货源占用,不再依赖入仓计划明细。结构必须与匹配Lastleg和Admin一同发布,失败立即停止,不回填旧数据。权限从IAM种子核对;文件存在不表示已执行。
|
`reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`,并首次创建 `ll_fba_appointment_command` 保存同事务幂等结果与历史。执行任何DROP前必须确认命令表尚不存在;若已存在,先核对上次执行结果,本脚本不清空命令历史、不支持盲目重试。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接及两张表的具体重置授权;不得作为自动启动脚本或用于生产。主单区分计划日期/时段、系统单号、外部确认号和确认UTC/IANA时区;明细按稳定货源占用,不再依赖入仓计划明细。结构必须与匹配Lastleg和Admin一同发布,失败立即停止,不回填旧数据。权限从IAM种子核对;文件存在不表示已执行。
|
||||||
|
|
||||||
`migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_unicode_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。
|
`migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_0900_ai_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。
|
||||||
|
|
||||||
`migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。
|
`migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。
|
||||||
|
|
||||||
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
|
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_0900_ai_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
|
||||||
|
|
||||||
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
|
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
|
||||||
|
|
||||||
@@ -50,6 +90,10 @@
|
|||||||
|
|
||||||
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
|
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
|
||||||
|
|
||||||
|
排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
|
||||||
|
|
||||||
|
订舱委/实资料在 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一空库基线同步维护:订舱主表增加条款、包装、品名、唛头和委/实三项货量,关系方增加邮箱、税号;已放舱主表触发器仅允许实际货量及更新审计元数据变化,运输、预报和创建信息冻结。匹配发布单元为 Firstleg 与 Admin;Print 继续使用现有模板。本轮只准备初始化定义,不执行 SQL、不回填测试存量、不提供新旧兼容。已有测试库须另行审阅、授权结构更新,不能重放完整空库基线。
|
||||||
|
|
||||||
## 唯一入口与区域边界
|
## 唯一入口与区域边界
|
||||||
|
|
||||||
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
|
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
|
||||||
@@ -58,8 +102,8 @@ 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` | 227 |
|
| 国内 | `头程数据库统一增量-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` | 228 |
|
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 222 |
|
||||||
|
|
||||||
|
|
||||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||||
@@ -82,7 +126,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
|||||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||||
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
||||||
|
|
||||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/222`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||||
|
|
||||||
## 平台管理员密码
|
## 平台管理员密码
|
||||||
|
|
||||||
@@ -178,3 +222,16 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
|||||||
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
||||||
|
|
||||||
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
|
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
|
||||||
|
|
||||||
|
## 统一业务单号
|
||||||
|
|
||||||
|
编号规则见 `../../../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);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 仅用于单独授权的 TEST:修正作废对账单历史明细阻止原费用重新生成对账单的问题。
|
||||||
|
-- 固定目标 MySQL 8 / InnoDB;保留全部账单、明细、金额及修订,不更新、删除或回填业务记录。
|
||||||
|
-- 单内同一费用版本保持唯一;跨单有效占用由 Finance 生成时的费用行锁和状态校验保证。
|
||||||
|
-- 前置:Finance 已具备生成时 AVAILABLE -> LOCKED 校验,以及作废单重开时的来源重新占用校验。
|
||||||
|
-- 执行前后核对 SHOW CREATE TABLE 与记录总数;本文件存在不表示已经执行。
|
||||||
|
-- 使用独立连接并开启遇错停止;一条原子 ALTER 替换索引,结果不明确先回查,不盲目重放。
|
||||||
|
ALTER TABLE baiyue_finance.fin_bill_line
|
||||||
|
DROP INDEX uk_fin_bill_line_source,
|
||||||
|
ADD UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
|
||||||
|
ADD KEY idx_fin_bill_line_source (source_type, source_id, source_version);
|
||||||
@@ -4,4 +4,4 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
|||||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||||
PRIMARY KEY(business_date)
|
PRIMARY KEY(business_date)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车单号每日独立原子流水';
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
-- MySQL 8 可重复执行增量:仓储转运到仓取货与本土卡车接入现有排车。
|
||||||
|
-- 目标数据库固定为 baiyue_lastleg;仅新增运输类型、原单关联及请求账本,不回填或删除业务数据。
|
||||||
|
-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
|
-- 排车保存会同步异常快照;旧库必须先补齐该正式字段。
|
||||||
|
SET @dispatch_exception_ddl = IF(
|
||||||
|
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'exceptions_json'),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE ll_dispatch_order ADD COLUMN exceptions_json JSON NULL COMMENT ''派送异常登记记录及图片引用,不改变派送及库存状态'' AFTER review_basis_hash'
|
||||||
|
);
|
||||||
|
PREPARE dispatch_exception_stmt FROM @dispatch_exception_ddl;
|
||||||
|
EXECUTE dispatch_exception_stmt;
|
||||||
|
DEALLOCATE PREPARE dispatch_exception_stmt;
|
||||||
|
|
||||||
|
SET @dispatch_transport_column_ddl = IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'task_type'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''任务类型:仓库派送、到仓取货、本土卡车'' AFTER dispatch_no'
|
||||||
|
);
|
||||||
|
PREPARE dispatch_transport_column_stmt FROM @dispatch_transport_column_ddl;
|
||||||
|
EXECUTE dispatch_transport_column_stmt;
|
||||||
|
DEALLOCATE PREPARE dispatch_transport_column_stmt;
|
||||||
|
|
||||||
|
-- 旧库兼容:整车主单跨客户,原来单客户排车头字段不再要求填写。
|
||||||
|
-- 如目标库已删除旧字段则跳过;已有客户值保留,不置空、不修改。
|
||||||
|
SET @dispatch_transport_legacy_ddl = IF(
|
||||||
|
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'customer_id' AND is_nullable = 'NO'),
|
||||||
|
'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NULL COMMENT ''旧客户排车归属,整车主单不使用''',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
||||||
|
EXECUTE dispatch_transport_legacy_stmt;
|
||||||
|
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
||||||
|
|
||||||
|
-- 旧本土卡车结构兼容:新单沿用已有的起终点联系人及pickup_at/delivery_at字段。
|
||||||
|
-- 旧单字段保留原值,仅放宽空值约束,避免新单被已弃用字段阻断。
|
||||||
|
SET @dispatch_transport_legacy_ddl = IF(
|
||||||
|
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_name' AND is_nullable = 'NO'),
|
||||||
|
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NULL COMMENT ''旧本土卡车联系人,新单使用起终点联系人''',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
||||||
|
EXECUTE dispatch_transport_legacy_stmt;
|
||||||
|
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
||||||
|
|
||||||
|
SET @dispatch_transport_legacy_ddl = IF(
|
||||||
|
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_info' AND is_nullable = 'NO'),
|
||||||
|
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NULL COMMENT ''旧本土卡车联系方式,新单使用起终点联系方式''',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
||||||
|
EXECUTE dispatch_transport_legacy_stmt;
|
||||||
|
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
||||||
|
|
||||||
|
SET @dispatch_transport_legacy_ddl = IF(
|
||||||
|
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'requested_date' AND is_nullable = 'NO'),
|
||||||
|
'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NULL COMMENT ''旧本土卡车请求日期,新单使用取货和送货时间''',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
||||||
|
EXECUTE dispatch_transport_legacy_stmt;
|
||||||
|
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||||
|
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||||
|
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||||
|
PRIMARY KEY (business_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车单号每日独立原子流水';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
|
||||||
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
||||||
|
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
||||||
|
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
||||||
|
PRIMARY KEY (dispatch_no),
|
||||||
|
UNIQUE KEY uk_transport_trip (trip_no),
|
||||||
|
UNIQUE KEY uk_transport_source (active_order),
|
||||||
|
KEY idx_transport_order (customer_id, order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车与本土卡车原单关联';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要',
|
||||||
|
PRIMARY KEY (trip_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本土卡车排车创建请求账本';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
|
||||||
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
||||||
|
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
||||||
|
PRIMARY KEY (dispatch_no, request_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车运输执行命令与凭证账本';
|
||||||
|
|
||||||
|
-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。
|
||||||
|
SET @dispatch_transport_pool_ddl = IF(
|
||||||
|
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND column_name = 'source_looseboard_order_id'),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''
|
||||||
|
);
|
||||||
|
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
|
||||||
|
EXECUTE dispatch_transport_pool_stmt;
|
||||||
|
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
|
||||||
|
|
||||||
|
SET @dispatch_transport_pool_ddl = IF(
|
||||||
|
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND index_name = 'idx_booking_looseboard_source'),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id, source_looseboard_order_id, status)'
|
||||||
|
);
|
||||||
|
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
|
||||||
|
EXECUTE dispatch_transport_pool_stmt;
|
||||||
|
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
||||||
|
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||||
|
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||||
|
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||||
|
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||||
|
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||||
|
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||||
|
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||||
|
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||||
|
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||||
|
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||||
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_channel (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
|
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
|
||||||
KEY idx_fl_sailing_channel_status (status)
|
KEY idx_fl_sailing_channel_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_sailing_template (
|
CREATE TABLE IF NOT EXISTS fl_sailing_template (
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_template (
|
|||||||
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
|
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
|
||||||
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
|
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
|
||||||
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
|
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_sailing (
|
CREATE TABLE IF NOT EXISTS fl_sailing (
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
@@ -100,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing (
|
|||||||
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
|
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
|
||||||
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
|
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
|
||||||
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
|
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_sailing_history (
|
CREATE TABLE IF NOT EXISTS fl_sailing_history (
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
@@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_history (
|
|||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
|
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 船期日历的事实契约。
|
* 船期日历的事实契约。
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
|
||||||
|
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
|
||||||
|
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
|
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
+3
-3
@@ -2,6 +2,6 @@
|
|||||||
-- Schema: baiyue_lastleg
|
-- Schema: baiyue_lastleg
|
||||||
-- Purpose: 拼车履约客户关联字段与现有订单域排序规则一致
|
-- Purpose: 拼车履约客户关联字段与现有订单域排序规则一致
|
||||||
-- Preconditions: 外接预订和商业履约桥已存在,客户字段为VARCHAR(128)且非空
|
-- Preconditions: 外接预订和商业履约桥已存在,客户字段为VARCHAR(128)且非空
|
||||||
-- Postconditions: 两个客户关联字段使用utf8mb4_unicode_ci;不修改客户标识或业务状态
|
-- Postconditions: 两个客户关联字段使用utf8mb4_0900_ai_ci;不修改客户标识或业务状态
|
||||||
ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属';
|
ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '委托客户及付款归属';
|
||||||
ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围';
|
ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '商业客户隔离范围';
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
-- Region: OVERSEAS
|
-- Region: OVERSEAS
|
||||||
-- Schema: baiyue_lastleg
|
-- Schema: baiyue_lastleg
|
||||||
-- Purpose: 仓储转运来货审核生成入仓计划时允许完整WAREHOUSE_TRANSFER来源值
|
-- Purpose: 仓储转运来货审核生成入仓计划时允许完整WAREHOUSE_TRANSFER来源值
|
||||||
-- Preconditions: ll_inbound_plan.creation_mode现为VARCHAR(16),utf8mb4_unicode_ci、NOT NULL、DEFAULT AUTO
|
-- Preconditions: ll_inbound_plan.creation_mode现为VARCHAR(16),utf8mb4_0900_ai_ci、NOT NULL、DEFAULT AUTO
|
||||||
-- Postconditions: 仅将本列扩至VARCHAR(32);保留排序规则、非空、默认值和已有业务数据
|
-- Postconditions: 仅将本列扩至VARCHAR(32);保留排序规则、非空、默认值和已有业务数据
|
||||||
ALTER TABLE ll_inbound_plan
|
ALTER TABLE ll_inbound_plan
|
||||||
MODIFY COLUMN creation_mode VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
MODIFY COLUMN creation_mode VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
|
||||||
NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运';
|
NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运';
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ CREATE TABLE ll_procurement_wallet_attempt (
|
|||||||
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
|
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
|
||||||
KEY idx_procurement_attempt_order(customer_id,order_id,status),
|
KEY idx_procurement_attempt_order(customer_id,order_id,status),
|
||||||
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
|
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用资金命令尝试与不可变结果';
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ CREATE TABLE ll_procurement_flow (
|
|||||||
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
|
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
|
||||||
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
|
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
|
||||||
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
|
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购单一批次履约事实';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_command (
|
CREATE TABLE ll_procurement_command (
|
||||||
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||||
@@ -42,7 +42,7 @@ CREATE TABLE ll_procurement_command (
|
|||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
|
||||||
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
|
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购节点幂等与不可变审计';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_budget_authorization (
|
CREATE TABLE ll_procurement_budget_authorization (
|
||||||
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||||
@@ -54,4 +54,4 @@ CREATE TABLE ll_procurement_budget_authorization (
|
|||||||
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
|
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
|
||||||
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
|
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
|
||||||
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
|
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购货款预算授权核验历史';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ CREATE TABLE ll_procurement_receipt (
|
|||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
|
||||||
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
|
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
|
||||||
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
|
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购分次实收与受控更正事实';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_disposition (
|
CREATE TABLE ll_procurement_disposition (
|
||||||
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||||
@@ -22,7 +22,7 @@ CREATE TABLE ll_procurement_disposition (
|
|||||||
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
||||||
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
|
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
|
||||||
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
|
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购单内差异处置,不自动退款';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_manifest (
|
CREATE TABLE ll_procurement_manifest (
|
||||||
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||||
@@ -31,7 +31,7 @@ CREATE TABLE ll_procurement_manifest (
|
|||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
|
||||||
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
|
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购装箱清单不可变版本';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_carton (
|
CREATE TABLE ll_procurement_carton (
|
||||||
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
|
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
|
||||||
@@ -40,12 +40,12 @@ CREATE TABLE ll_procurement_carton (
|
|||||||
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
|
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
|
||||||
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
|
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购版本化逐箱实测与件数';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_carton_source (
|
CREATE TABLE ll_procurement_carton_source (
|
||||||
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
|
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
|
||||||
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
|
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购箱内货物的实收来源与数量';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_shipment (
|
CREATE TABLE ll_procurement_shipment (
|
||||||
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||||
@@ -55,11 +55,11 @@ CREATE TABLE ll_procurement_shipment (
|
|||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
|
||||||
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
|
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购两段各一次整批真实交寄';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_shipment_item (
|
CREATE TABLE ll_procurement_shipment_item (
|
||||||
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
|
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
|
||||||
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
|
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
|
||||||
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
|
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
|
||||||
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
|
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购整批交寄逐箱事实';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ CREATE TABLE ll_procurement_label_set (
|
|||||||
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
|
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
|
||||||
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
|
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购两段面单不可变版本和地址快照';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_label_item (
|
CREATE TABLE ll_procurement_label_item (
|
||||||
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
|
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
|
||||||
@@ -15,14 +15,14 @@ CREATE TABLE ll_procurement_label_item (
|
|||||||
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
|
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
|
||||||
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
|
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
|
||||||
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
|
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购逐箱文件页关联及贴单事实';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_estimate_review (
|
CREATE TABLE ll_procurement_estimate_review (
|
||||||
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
|
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
|
||||||
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
|
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
|
||||||
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
|
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='面单清单与预估冻结的显式核对关联';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_repack (
|
CREATE TABLE ll_procurement_repack (
|
||||||
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
|
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
|
||||||
@@ -32,9 +32,9 @@ CREATE TABLE ll_procurement_repack (
|
|||||||
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||||
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
|
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
|
||||||
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
|
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购国内原箱一对一换包装不可变历史';
|
||||||
|
|
||||||
ALTER TABLE ll_overseas_procurement_file
|
ALTER TABLE ll_overseas_procurement_file
|
||||||
DROP CHECK chk_ll_procurement_file_category,
|
DROP CHECK chk_ll_procurement_file_category,
|
||||||
MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证',
|
MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证',
|
||||||
ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'));
|
ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'));
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ CREATE TABLE ll_procurement_fee_correction (
|
|||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
|
||||||
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
|
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
|
||||||
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
|
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用链更正目标与原汇率快照';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_fee_correction_item (
|
CREATE TABLE ll_procurement_fee_correction_item (
|
||||||
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
|
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
|
||||||
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
|
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
|
||||||
|
|||||||
+5
-5
@@ -133,15 +133,15 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|||||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
|
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
|
||||||
id bigint NOT NULL COMMENT '测量照片引用ID',
|
id bigint NOT NULL COMMENT '测量照片引用ID',
|
||||||
transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID',
|
transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID',
|
||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
customer_id varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户租户ID',
|
||||||
file_id bigint NOT NULL COMMENT '文件服务文件ID',
|
file_id bigint NOT NULL COMMENT '文件服务文件ID',
|
||||||
file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照',
|
file_name varchar(255) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件名快照',
|
||||||
content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照',
|
content_type varchar(128) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件媒体类型快照',
|
||||||
size_bytes bigint NOT NULL DEFAULT 0 COMMENT '文件字节数快照',
|
size_bytes bigint NOT NULL DEFAULT 0 COMMENT '文件字节数快照',
|
||||||
uploaded_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人',
|
uploaded_by varchar(128) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'PDA上传操作人',
|
||||||
uploaded_at datetime(3) NOT NULL COMMENT '上传时间',
|
uploaded_at datetime(3) NOT NULL COMMENT '上传时间',
|
||||||
submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间',
|
submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id),
|
UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id),
|
||||||
KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at)
|
KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA测量现场照片引用';
|
||||||
|
|||||||
+3
-3
@@ -10,7 +10,7 @@ CREATE TABLE ll_procurement_control_note (
|
|||||||
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
|
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
|
||||||
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
|
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_closure (
|
CREATE TABLE ll_procurement_closure (
|
||||||
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||||
@@ -19,7 +19,7 @@ CREATE TABLE ll_procurement_closure (
|
|||||||
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
|
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
|
||||||
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
|
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
|
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购实物资金收口后的异常关闭事实';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_closure_item (
|
CREATE TABLE ll_procurement_closure_item (
|
||||||
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
|
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
|
||||||
@@ -28,4 +28,4 @@ CREATE TABLE ll_procurement_closure_item (
|
|||||||
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
|
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
|
||||||
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
|
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
|
||||||
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
|
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
|
||||||
|
|||||||
+31
-31
@@ -2,43 +2,43 @@
|
|||||||
-- Schema: baiyue_lastleg
|
-- Schema: baiyue_lastleg
|
||||||
-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求
|
-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求
|
||||||
-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突
|
-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突
|
||||||
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_unicode_ci;不修改数量、状态或业务标识
|
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_0900_ai_ci;不修改数量、状态或业务标识
|
||||||
-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。
|
-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。
|
||||||
ALTER TABLE ll_return_order
|
ALTER TABLE ll_return_order
|
||||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
|
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户ID和隔离键',
|
||||||
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID';
|
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作海外仓ID';
|
||||||
ALTER TABLE ll_return_order_item
|
ALTER TABLE ll_return_order_item
|
||||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
|
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码',
|
||||||
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
|
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名';
|
||||||
ALTER TABLE ll_return_receipt
|
ALTER TABLE ll_return_receipt
|
||||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号';
|
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '签收单号';
|
||||||
ALTER TABLE ll_return_receipt_item
|
ALTER TABLE ll_return_receipt_item
|
||||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码';
|
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码';
|
||||||
ALTER TABLE ll_return_inventory_carton
|
ALTER TABLE ll_return_inventory_carton
|
||||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
|
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户ID和隔离键',
|
||||||
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
|
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '海外仓ID',
|
||||||
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码';
|
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '退货箱号和箱唛编码';
|
||||||
ALTER TABLE ll_return_inventory_carton_item
|
ALTER TABLE ll_return_inventory_carton_item
|
||||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
|
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码',
|
||||||
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
|
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名';
|
||||||
ALTER TABLE ll_return_inventory_outbound_movement
|
ALTER TABLE ll_return_inventory_outbound_movement
|
||||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
|
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
|
||||||
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID',
|
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '实际出库海外仓ID',
|
||||||
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照',
|
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次出库SKU编码快照',
|
||||||
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
|
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名快照',
|
||||||
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号';
|
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次退货出库业务单号';
|
||||||
ALTER TABLE ll_return_sku_conversion
|
ALTER TABLE ll_return_sku_conversion
|
||||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键',
|
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户隔离键',
|
||||||
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
|
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作海外仓ID',
|
||||||
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU',
|
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标前SKU',
|
||||||
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU',
|
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标后SKU',
|
||||||
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
|
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名快照',
|
||||||
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号';
|
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标单号';
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
|
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
|
||||||
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
|
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
|
|||||||
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
||||||
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
|
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
|
||||||
PRIMARY KEY (number_key)
|
PRIMARY KEY (number_key)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海外业务短单号分配时钟';
|
||||||
|
|
||||||
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|||||||
-- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。
|
-- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
@@ -35,36 +35,36 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|||||||
-- 生产环境当前缺失、测试已存在的换标与退货字段。
|
-- 生产环境当前缺失、测试已存在的换标与退货字段。
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1');
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1');
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1');
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(
|
SET @ddl = IF(
|
||||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci'))
|
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci'))
|
||||||
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')),
|
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci')),
|
||||||
'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''原海外中转仓ID''',
|
'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''原海外中转仓ID''',
|
||||||
'SELECT 1'
|
'SELECT 1'
|
||||||
);
|
);
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1');
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1');
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
SET @ddl = IF(
|
SET @ddl = IF(
|
||||||
(SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_unicode_ci',
|
(SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_0900_ai_ci',
|
||||||
'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
|
'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci',
|
||||||
'SELECT 1'
|
'SELECT 1'
|
||||||
);
|
);
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- 仓储取货与本土卡车接入现有排车;保留旧排车用途默认值。
|
||||||
|
ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车' AFTER dispatch_no;
|
||||||
|
CREATE TABLE ll_dispatch_truck_task (
|
||||||
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
||||||
|
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
||||||
|
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
||||||
|
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
|
||||||
|
KEY idx_transport_order(customer_id,order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车与本土卡车原单关联';
|
||||||
|
CREATE TABLE ll_dispatch_transport_create (
|
||||||
|
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本土卡车排车创建请求账本';
|
||||||
|
CREATE TABLE ll_dispatch_transport_command (
|
||||||
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
||||||
|
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
||||||
|
PRIMARY KEY(dispatch_no,request_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车运输执行命令与凭证账本';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Region: OVERSEAS
|
||||||
|
-- Schema: baiyue_lastleg
|
||||||
|
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
|
||||||
|
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
|
||||||
|
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
|
||||||
|
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
|
||||||
|
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
||||||
|
EXECUTE looseboard_demand_stmt;
|
||||||
|
DEALLOCATE PREPARE looseboard_demand_stmt;
|
||||||
|
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
|
||||||
|
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
||||||
|
EXECUTE looseboard_demand_stmt;
|
||||||
|
DEALLOCATE PREPARE looseboard_demand_stmt;
|
||||||
@@ -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,10 @@
|
|||||||
|
-- Region: OVERSEAS
|
||||||
|
-- Schema: baiyue_lastleg
|
||||||
|
-- Purpose: 预算释放查询的客户和订单号关联字段与采购单、卡车单排序规则一致
|
||||||
|
-- Preconditions: ll_order_budget_reservation 已存在;核对列长度、可空性及索引
|
||||||
|
-- Postconditions: customer_id、order_no 使用 utf8mb4_0900_ai_ci;不修改任何业务数据
|
||||||
|
-- DDL 非事务,执行前核对目标库;失败后检查列定义再继续。
|
||||||
|
ALTER TABLE baiyue_lastleg.ll_order_budget_reservation
|
||||||
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
|
||||||
|
MODIFY COLUMN customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户隔离标识',
|
||||||
|
MODIFY COLUMN order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '成功创建的业务单号';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE ll_local_looseboard_order
|
||||||
|
ADD COLUMN delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货',
|
||||||
|
ADD COLUMN operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID',
|
||||||
|
ADD COLUMN destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址',
|
||||||
|
ADD COLUMN destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编',
|
||||||
|
ADD COLUMN delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人',
|
||||||
|
ADD COLUMN delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话',
|
||||||
|
ADD COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
|
||||||
|
ADD COLUMN tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号',
|
||||||
|
ADD COLUMN transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号';
|
||||||
@@ -8,5 +8,5 @@ ALTER TABLE ll_outbound_pda_pick
|
|||||||
MODIFY COLUMN inventory_position_id bigint NULL COMMENT '库存仓位ID;退货箱直接出库时为空';
|
MODIFY COLUMN inventory_position_id bigint NULL COMMENT '库存仓位ID;退货箱直接出库时为空';
|
||||||
|
|
||||||
ALTER TABLE ll_relabel_item
|
ALTER TABLE ll_relabel_item
|
||||||
MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空',
|
MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空',
|
||||||
MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空';
|
MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空';
|
||||||
|
|||||||
+1
-1
@@ -27,4 +27,4 @@ CREATE TABLE ll_warehouse_transfer_receipt_command (
|
|||||||
UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key),
|
UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key),
|
||||||
KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id),
|
KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id),
|
||||||
CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED'))
|
CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令';
|
||||||
|
|||||||
@@ -0,0 +1,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_0900_ai_ci')<>1
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
|
||||||
|
AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA=''
|
||||||
|
AND ((COLUMN_NAME IN ('receipt_id','lot_id') AND COLUMN_TYPE='bigint')
|
||||||
|
OR (COLUMN_NAME='quantity' AND COLUMN_TYPE='decimal(24,12)')
|
||||||
|
OR (COLUMN_NAME='created_at' AND COLUMN_TYPE='datetime(3)')))<>4 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的计租实收表字段与正式定义不同,停止且不覆盖';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY')<>1
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY'
|
||||||
|
AND COLUMN_NAME='receipt_id' AND SEQ_IN_INDEX=1 AND NON_UNIQUE=0 AND SUB_PART IS NULL)<>1
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
|
||||||
|
AND INDEX_NAME='idx_storage_rent_inbound_lot')<>1
|
||||||
|
OR (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
|
||||||
|
AND INDEX_NAME='idx_storage_rent_inbound_lot' AND COLUMN_NAME='lot_id'
|
||||||
|
AND SEQ_IN_INDEX=1 AND NON_UNIQUE=1 AND SUB_PART IS NULL)<>1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='计租实收表主键或批次索引不匹配,停止且不覆盖';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 全部前置检查通过后才补缺项;重跑时保持已存在的业务结构和数据。
|
||||||
|
IF reviewer_exists=0 THEN
|
||||||
|
ALTER TABLE ll_finance_fee_source
|
||||||
|
ADD COLUMN reviewer_name VARCHAR(255) NULL COMMENT '审核人名称快照' AFTER reviewer_id;
|
||||||
|
END IF;
|
||||||
|
IF inbound_exists=0 THEN
|
||||||
|
CREATE TABLE ll_storage_rent_inbound (
|
||||||
|
receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量',
|
||||||
|
lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键',
|
||||||
|
quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数',
|
||||||
|
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
|
||||||
|
PRIMARY KEY (receipt_id),
|
||||||
|
KEY idx_storage_rent_inbound_lot (lot_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程实收计租幂等登记';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL overseas_fee_rent_structure_add_20261026();
|
||||||
|
DROP PROCEDURE overseas_fee_rent_structure_add_20261026;
|
||||||
|
|
||||||
|
SHOW CREATE TABLE ll_storage_rent_inbound;
|
||||||
|
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name';
|
||||||
|
SELECT 'TABLE_COLUMN_READY' AS structure_status;
|
||||||
|
-- TABLE_COLUMN_READY 只表示上述表/字段补齐;免费仓租另执行已审阅的
|
||||||
|
-- overseas-storage-rent-zero-fee-repair.sql,不能据此宣称完整功能上线。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因'''));
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照'''));
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
|
||||||
|
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
|
||||||
|
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
|
||||||
|
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
|
||||||
|
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
|
||||||
|
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
|
||||||
|
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
|
||||||
|
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
|
||||||
|
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
|
||||||
|
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
|
||||||
|
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
|
||||||
|
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
|
||||||
|
KEY idx_order_budget_release(status,release_requested,created_at),
|
||||||
|
KEY idx_order_budget_order(customer_id,kind,order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- 海外采购旧流程专属表退役,MySQL 8.0+,目标仅 baiyue_lastleg。
|
||||||
|
-- 必须先停止旧版 Lastleg 写入并部署本次分离版本,验证新流程后在维护窗口执行。
|
||||||
|
-- 永久删除下面明确列出的 21 张旧采购表及其记录,DDL 隐式提交;禁止 mysql --force。
|
||||||
|
-- 保留主单、审核流水、采购附件、分批收货、出库及幂等六表;不改任何共用财务、资金、文件或仓储表。
|
||||||
|
-- 旧资金表有记录或旧状态采购未处理时拒绝,不自动清除、回填或伪造资金完成。
|
||||||
|
-- 运行者须在同一连接显式 SET @procurement_retirement_release='procurement-six-tables-20261027';
|
||||||
|
-- 文件准备不表示数据库已执行。先审核备份及运行版本,按清单整份执行,遇错停止。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
DELIMITER $$
|
||||||
|
DROP PROCEDURE IF EXISTS retire_procurement_legacy_tables$$
|
||||||
|
CREATE PROCEDURE retire_procurement_legacy_tables()
|
||||||
|
BEGIN
|
||||||
|
IF DATABASE()<>'baiyue_lastleg' OR COALESCE(@procurement_retirement_release,'')<>'procurement-six-tables-20261027' THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Confirm the six-table procurement release and target schema first';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN (
|
||||||
|
'll_overseas_procurement_order','ll_overseas_procurement_status_history','ll_overseas_procurement_file',
|
||||||
|
'll_procurement_workflow_receipt','ll_procurement_workflow_outbound','ll_procurement_workflow_command'))<>6 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Six active procurement tables must exist before retiring legacy tables';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM ll_overseas_procurement_order WHERE status NOT IN
|
||||||
|
('PENDING_REVIEW','RECEIVING','WAIT_CUSTOMER_CONFIRM','CONFIRMED','IN_TRANSIT','DELIVERING','COMPLETED','REJECTED','CANCELLED')) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement orders remain; resolve them before retirement';
|
||||||
|
END IF;
|
||||||
|
-- 仅只读核对共用资金记录;不删除资金命令或回执,不把已投递当作已结清。
|
||||||
|
IF EXISTS(SELECT 1 FROM ll_event_outbox WHERE aggregate_type='PROCUREMENT_FEE')
|
||||||
|
OR EXISTS(SELECT 1 FROM ll_wallet_event_inbox
|
||||||
|
WHERE JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.businessType'))='PROCUREMENT_FEE') THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement wallet events exist; financial review is required';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_fee') THEN
|
||||||
|
SET @procurement_retirement_financial_rows=0;
|
||||||
|
SET @procurement_retirement_check_sql='SELECT COUNT(*) INTO @procurement_retirement_financial_rows FROM baiyue_lastleg.ll_overseas_procurement_fee';
|
||||||
|
PREPARE procurement_retirement_check FROM @procurement_retirement_check_sql;
|
||||||
|
EXECUTE procurement_retirement_check;
|
||||||
|
DEALLOCATE PREPARE procurement_retirement_check;
|
||||||
|
IF @procurement_retirement_financial_rows<>0 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement financial records remain; do not delete financial evidence';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_procurement_wallet_attempt') THEN
|
||||||
|
SET @procurement_retirement_financial_rows=0;
|
||||||
|
SET @procurement_retirement_check_sql='SELECT COUNT(*) INTO @procurement_retirement_financial_rows FROM baiyue_lastleg.ll_procurement_wallet_attempt';
|
||||||
|
PREPARE procurement_retirement_check FROM @procurement_retirement_check_sql;
|
||||||
|
EXECUTE procurement_retirement_check;
|
||||||
|
DEALLOCATE PREPARE procurement_retirement_check;
|
||||||
|
IF @procurement_retirement_financial_rows<>0 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement financial records remain; do not delete financial evidence';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
-- 不关闭外键校验;来自其他业务表的外键也会使这条精确删除失败。
|
||||||
|
DROP TABLE IF EXISTS
|
||||||
|
ll_procurement_closure_item,
|
||||||
|
ll_procurement_closure,
|
||||||
|
ll_procurement_control_note,
|
||||||
|
ll_procurement_fee_correction_item,
|
||||||
|
ll_procurement_fee_correction,
|
||||||
|
ll_procurement_repack,
|
||||||
|
ll_procurement_estimate_review,
|
||||||
|
ll_procurement_label_item,
|
||||||
|
ll_procurement_label_set,
|
||||||
|
ll_procurement_shipment_item,
|
||||||
|
ll_procurement_shipment,
|
||||||
|
ll_procurement_carton_source,
|
||||||
|
ll_procurement_carton,
|
||||||
|
ll_procurement_manifest,
|
||||||
|
ll_procurement_disposition,
|
||||||
|
ll_procurement_receipt,
|
||||||
|
ll_procurement_budget_authorization,
|
||||||
|
ll_procurement_command,
|
||||||
|
ll_procurement_flow,
|
||||||
|
ll_procurement_wallet_attempt,
|
||||||
|
ll_overseas_procurement_fee;
|
||||||
|
END$$
|
||||||
|
CALL retire_procurement_legacy_tables()$$
|
||||||
|
DROP PROCEDURE retire_procurement_legacy_tables$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- 若上面任一步失败必须停止。剩余旧表数应为 0;此 SELECT 不代替客户端退出码核对。
|
||||||
|
SELECT COUNT(*) AS remaining_legacy_procurement_tables
|
||||||
|
FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||||
|
'll_overseas_procurement_fee',
|
||||||
|
'll_procurement_wallet_attempt',
|
||||||
|
'll_procurement_flow',
|
||||||
|
'll_procurement_command',
|
||||||
|
'll_procurement_budget_authorization',
|
||||||
|
'll_procurement_receipt',
|
||||||
|
'll_procurement_disposition',
|
||||||
|
'll_procurement_manifest',
|
||||||
|
'll_procurement_carton',
|
||||||
|
'll_procurement_carton_source',
|
||||||
|
'll_procurement_shipment',
|
||||||
|
'll_procurement_shipment_item',
|
||||||
|
'll_procurement_label_set',
|
||||||
|
'll_procurement_label_item',
|
||||||
|
'll_procurement_estimate_review',
|
||||||
|
'll_procurement_repack',
|
||||||
|
'll_procurement_fee_correction',
|
||||||
|
'll_procurement_fee_correction_item',
|
||||||
|
'll_procurement_control_note',
|
||||||
|
'll_procurement_closure',
|
||||||
|
'll_procurement_closure_item');
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- 海外采购退役表只读核查;2026-10-27 分离版本。MySQL 8.0+,仅查询,不自动执行删除。
|
||||||
|
-- 6 张当前业务表保留;21 张旧采购专属表已从生产代码和初始化基线移除。
|
||||||
|
-- 只有先部署分离版本并通过 legacy-tables-retire.sql 前置校验后,才可执行精确删表清单。
|
||||||
|
-- 元数据行数是估算;actual_count_sql 供逐表只读核对,不自动执行。
|
||||||
|
WITH inventory AS (
|
||||||
|
SELECT 'll_overseas_procurement_order' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_overseas_procurement_status_history' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_overseas_procurement_file' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_workflow_receipt' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_workflow_outbound' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_workflow_command' AS table_name, 'KEEP' AS disposition
|
||||||
|
UNION ALL SELECT 'll_overseas_procurement_fee' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_wallet_attempt' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_flow' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_command' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_budget_authorization' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_receipt' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_disposition' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_manifest' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_carton' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_carton_source' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_shipment' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_shipment_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_label_set' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_label_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_estimate_review' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_repack' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_fee_correction' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_fee_correction_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_control_note' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_closure' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
UNION ALL SELECT 'll_procurement_closure_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition
|
||||||
|
)
|
||||||
|
SELECT i.table_name,i.disposition,CASE WHEN t.table_name IS NULL THEN 'MISSING' ELSE 'EXISTS' END AS database_state,
|
||||||
|
t.table_rows AS table_rows_estimate,
|
||||||
|
CASE WHEN t.table_name IS NOT NULL THEN CONCAT('SELECT COUNT(*) AS actual_rows FROM baiyue_lastleg.',i.table_name,';') END AS actual_count_sql
|
||||||
|
FROM inventory i LEFT JOIN information_schema.tables t ON t.table_schema='baiyue_lastleg' AND t.table_name=i.table_name
|
||||||
|
ORDER BY i.disposition,i.table_name;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- 仅用于用户确认可丢弃数据的海外采购测试环境,MySQL 8.0+。
|
||||||
|
-- 清空六张当前采购表的数据,删除二十一张旧采购表及其数据。
|
||||||
|
-- 仅作用于 baiyue_lastleg 的采购专属表;不修改共用费用、余额、文件对象及库存。
|
||||||
|
-- 共用表中已有的采购关联记录不会自动删除或退款;本脚本不用于财务结算。
|
||||||
|
-- 先更新分离后的后端并停止采购写入,整份执行,遇错停止,不使用 --force。
|
||||||
|
-- 无存储过程、无 DELIMITER、无需设置会话确认变量;DDL 隐式提交。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
START TRANSACTION;
|
||||||
|
DELETE FROM ll_procurement_workflow_command;
|
||||||
|
DELETE FROM ll_procurement_workflow_outbound;
|
||||||
|
DELETE FROM ll_procurement_workflow_receipt;
|
||||||
|
DELETE FROM ll_overseas_procurement_file;
|
||||||
|
DELETE FROM ll_overseas_procurement_status_history;
|
||||||
|
DELETE FROM ll_overseas_procurement_order;
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS
|
||||||
|
ll_procurement_closure_item,
|
||||||
|
ll_procurement_closure,
|
||||||
|
ll_procurement_control_note,
|
||||||
|
ll_procurement_fee_correction_item,
|
||||||
|
ll_procurement_fee_correction,
|
||||||
|
ll_procurement_repack,
|
||||||
|
ll_procurement_estimate_review,
|
||||||
|
ll_procurement_label_item,
|
||||||
|
ll_procurement_label_set,
|
||||||
|
ll_procurement_shipment_item,
|
||||||
|
ll_procurement_shipment,
|
||||||
|
ll_procurement_carton_source,
|
||||||
|
ll_procurement_carton,
|
||||||
|
ll_procurement_manifest,
|
||||||
|
ll_procurement_disposition,
|
||||||
|
ll_procurement_receipt,
|
||||||
|
ll_procurement_budget_authorization,
|
||||||
|
ll_procurement_command,
|
||||||
|
ll_procurement_flow,
|
||||||
|
ll_procurement_wallet_attempt,
|
||||||
|
ll_overseas_procurement_fee;
|
||||||
|
|
||||||
|
SELECT 'procurement_test_reset_complete' AS result,
|
||||||
|
(SELECT COUNT(*) FROM ll_overseas_procurement_order) AS procurement_orders,
|
||||||
|
(SELECT COUNT(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||||
|
'll_overseas_procurement_fee',
|
||||||
|
'll_procurement_wallet_attempt',
|
||||||
|
'll_procurement_flow',
|
||||||
|
'll_procurement_command',
|
||||||
|
'll_procurement_budget_authorization',
|
||||||
|
'll_procurement_receipt',
|
||||||
|
'll_procurement_disposition',
|
||||||
|
'll_procurement_manifest',
|
||||||
|
'll_procurement_carton',
|
||||||
|
'll_procurement_carton_source',
|
||||||
|
'll_procurement_shipment',
|
||||||
|
'll_procurement_shipment_item',
|
||||||
|
'll_procurement_label_set',
|
||||||
|
'll_procurement_label_item',
|
||||||
|
'll_procurement_estimate_review',
|
||||||
|
'll_procurement_repack',
|
||||||
|
'll_procurement_fee_correction',
|
||||||
|
'll_procurement_fee_correction_item',
|
||||||
|
'll_procurement_control_note',
|
||||||
|
'll_procurement_closure',
|
||||||
|
'll_procurement_closure_item'
|
||||||
|
)) AS remaining_legacy_procurement_tables;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- 简化采购独立批次表;只增加结构,不回填旧采购记录,执行需另行授权。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
-- 简化采购:独立采购库存,收货、占用和签收扣减均由订单锁串行控制。
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
quantity BIGINT NOT NULL COMMENT '本批次数量',
|
||||||
|
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
|
||||||
|
received_at DATETIME(3) NOT NULL COMMENT '本批次实收时间',
|
||||||
|
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
KEY idx_procurement_workflow_receipt (order_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批收货照片与实收数量';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键',
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
outbound_no VARCHAR(64) NOT NULL COMMENT '采购出库单号',
|
||||||
|
quantity BIGINT NOT NULL COMMENT '本批次数量',
|
||||||
|
tracking_no VARCHAR(255) NOT NULL COMMENT '物流跟踪号',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '批次运输派送及签收状态',
|
||||||
|
file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '记录创建时间',
|
||||||
|
completed_at DATETIME(3) NULL COMMENT '本批次签收完成时间',
|
||||||
|
created_by VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
UNIQUE KEY uk_procurement_workflow_outbound_no (outbound_no),
|
||||||
|
KEY idx_procurement_workflow_outbound (order_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批出库物流与签收凭证';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
|
||||||
|
order_id BIGINT NOT NULL COMMENT '采购单主键',
|
||||||
|
command_key VARCHAR(128) NOT NULL COMMENT '本单操作幂等键',
|
||||||
|
request_hash VARCHAR(64) NOT NULL COMMENT '动作及请求参数哈希',
|
||||||
|
operator_id VARCHAR(128) NOT NULL COMMENT '作业人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
|
||||||
|
PRIMARY KEY (order_id,command_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
|
||||||
@@ -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;
|
||||||
@@ -52,7 +52,7 @@ CREATE TABLE ll_inbound_fba_appointment (
|
|||||||
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||||
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
|
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
|
||||||
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
|
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FBA预约安排与正式确认唯一主记录';
|
||||||
|
|
||||||
CREATE TABLE ll_inbound_fba_appointment_item (
|
CREATE TABLE ll_inbound_fba_appointment_item (
|
||||||
id BIGINT NOT NULL COMMENT '记录主键',
|
id BIGINT NOT NULL COMMENT '记录主键',
|
||||||
@@ -74,7 +74,7 @@ CREATE TABLE ll_inbound_fba_appointment_item (
|
|||||||
KEY idx_ll_fba_active_order (customer_id,order_id,active),
|
KEY idx_ll_fba_active_order (customer_id,order_id,active),
|
||||||
KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
|
KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
|
||||||
KEY idx_ll_fba_item_customer_order (customer_id,order_id)
|
KEY idx_ll_fba_item_customer_order (customer_id,order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入仓前后共用的预约真实货源';
|
||||||
|
|
||||||
CREATE TABLE ll_appointment_cargo_verification (
|
CREATE TABLE ll_appointment_cargo_verification (
|
||||||
id BIGINT NOT NULL COMMENT '核实记录主键',
|
id BIGINT NOT NULL COMMENT '核实记录主键',
|
||||||
@@ -97,7 +97,7 @@ CREATE TABLE ll_appointment_cargo_verification (
|
|||||||
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
|
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
|
||||||
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
|
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
|
||||||
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
|
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
|
||||||
|
|
||||||
CREATE TABLE ll_fba_appointment_command (
|
CREATE TABLE ll_fba_appointment_command (
|
||||||
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
|
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
|
||||||
@@ -109,4 +109,4 @@ CREATE TABLE ll_fba_appointment_command (
|
|||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
||||||
PRIMARY KEY(command_key),
|
PRIMARY KEY(command_key),
|
||||||
KEY idx_ll_fba_command_appointment(appointment_no,created_at)
|
KEY idx_ll_fba_command_appointment(appointment_no,created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约命令与变更历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='预约命令与变更历史';
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 在 Lastleg 所属库执行;只补充整托计租截止日,不改写既有费用、库存、冻结或资金流水。
|
||||||
|
-- 发布新服务前暂停仓租出库,取消未消费预占;旧比例费用的处理需按业务单核对后单独办理。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
SET @return_rent_ddl = (
|
||||||
|
SELECT IF(EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'll_storage_rent_lot' AND column_name = 'charged_days'
|
||||||
|
), 'SELECT 1',
|
||||||
|
'ALTER TABLE ll_storage_rent_lot ADD COLUMN charged_days BIGINT NOT NULL DEFAULT 0 COMMENT ''退货原托已生成仓租的截止库龄天数,同一天不重复计费'' AFTER original_units')
|
||||||
|
);
|
||||||
|
PREPARE return_rent_statement FROM @return_rent_ddl;
|
||||||
|
EXECUTE return_rent_statement;
|
||||||
|
DEALLOCATE PREPARE return_rent_statement;
|
||||||
@@ -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;
|
||||||
+118
-34
@@ -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。
|
||||||
@@ -1484,7 +1485,6 @@ CREATE TABLE fl_container_booking (
|
|||||||
KEY idx_container_booking_voyage (voyage_no,updated_at),
|
KEY idx_container_booking_voyage (voyage_no,updated_at),
|
||||||
KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type),
|
KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type),
|
||||||
CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))),
|
CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))),
|
||||||
CONSTRAINT chk_container_booking_iso6346 CHECK (((container_no is null) or regexp_like(container_no,_utf8mb4'^[A-Z]{3}[UJZ][0-9]{7}$'))),
|
|
||||||
CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))),
|
CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))),
|
||||||
CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))),
|
CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))),
|
||||||
CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))),
|
CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))),
|
||||||
@@ -1981,11 +1981,11 @@ CREATE TABLE fl_customs_clearance_result (
|
|||||||
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
|
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
|
||||||
|
|
||||||
CREATE TABLE fl_document_sequence (
|
CREATE TABLE fl_document_sequence (
|
||||||
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
|
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
|
||||||
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
|
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
|
||||||
|
|
||||||
CREATE TABLE fl_fee_item (
|
CREATE TABLE fl_fee_item (
|
||||||
id bigint unsigned NOT NULL COMMENT '费用项目ID',
|
id bigint unsigned NOT NULL COMMENT '费用项目ID',
|
||||||
@@ -3093,7 +3093,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
|
|||||||
seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码',
|
seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码',
|
||||||
remark varchar(1000) DEFAULT NULL COMMENT '证据备注',
|
remark varchar(1000) DEFAULT NULL COMMENT '证据备注',
|
||||||
status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
|
status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
|
||||||
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,保证每柜每类型仅一条有效事实',
|
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,同类允许多张有效照片',
|
||||||
operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键',
|
operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键',
|
||||||
request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
|
request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
|
||||||
void_reason varchar(500) DEFAULT NULL COMMENT '作废原因',
|
void_reason varchar(500) DEFAULT NULL COMMENT '作废原因',
|
||||||
@@ -3109,7 +3109,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
|
|||||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
|
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
|
||||||
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
|
KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
|
||||||
KEY idx_cabinet_evidence_container (container_booking_id,status),
|
KEY idx_cabinet_evidence_container (container_booking_id,status),
|
||||||
KEY idx_cabinet_evidence_seal (seal_no,active_flag),
|
KEY idx_cabinet_evidence_seal (seal_no,active_flag),
|
||||||
CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id),
|
CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id),
|
||||||
@@ -3442,7 +3442,7 @@ CREATE TABLE fl_mojia_measurement_receipt (
|
|||||||
CREATE TABLE fl_ocean_booking (
|
CREATE TABLE fl_ocean_booking (
|
||||||
id bigint unsigned NOT NULL COMMENT '海运订舱ID',
|
id bigint unsigned NOT NULL COMMENT '海运订舱ID',
|
||||||
booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号',
|
booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号',
|
||||||
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
|
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
|
||||||
customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键',
|
customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键',
|
||||||
customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
|
customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
|
||||||
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
|
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||||
@@ -3465,6 +3465,17 @@ CREATE TABLE fl_ocean_booking (
|
|||||||
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
|
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
|
||||||
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间',
|
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间',
|
||||||
remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注',
|
remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注',
|
||||||
|
freight_term VARCHAR(64) NULL COMMENT '运费条款',
|
||||||
|
service_term VARCHAR(64) NULL COMMENT '运输条款',
|
||||||
|
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
|
||||||
|
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
|
||||||
|
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
|
||||||
|
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
|
||||||
|
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
|
||||||
|
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
|
||||||
|
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
|
||||||
|
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
|
||||||
|
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
|
||||||
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
|
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
|
||||||
created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照',
|
created_by_name varchar(64) 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 '订舱记录创建时间',
|
||||||
@@ -3479,6 +3490,14 @@ CREATE TABLE fl_ocean_booking (
|
|||||||
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
|
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
|
||||||
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
|
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
|
||||||
CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)),
|
CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)),
|
||||||
|
CONSTRAINT chk_ocean_booking_cargo CHECK (
|
||||||
|
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
|
||||||
|
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
|
||||||
|
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
|
||||||
|
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
|
||||||
|
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
|
||||||
|
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
|
||||||
|
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
|
||||||
CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))),
|
CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))),
|
||||||
CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
|
CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||||
@@ -3565,6 +3584,8 @@ CREATE TABLE fl_ocean_booking_party (
|
|||||||
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
|
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
|
||||||
contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照',
|
contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照',
|
||||||
contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照',
|
contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照',
|
||||||
|
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
|
||||||
|
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
||||||
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE,
|
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE,
|
||||||
@@ -3832,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),
|
||||||
@@ -4121,7 +4144,7 @@ CREATE TABLE fl_direct_delivery_unloading (
|
|||||||
PRIMARY KEY (container_booking_id,inbound_id),
|
PRIMARY KEY (container_booking_id,inbound_id),
|
||||||
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
|
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
|
||||||
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
|
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直送目的仓真实卸货凭据';
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE fl_terminal_drayage (
|
CREATE TABLE fl_terminal_drayage (
|
||||||
@@ -4437,20 +4460,6 @@ CREATE TABLE fl_inbound_split_item (
|
|||||||
|
|
||||||
SET SESSION foreign_key_checks = 1;
|
SET SESSION foreign_key_checks = 1;
|
||||||
|
|
||||||
-- 客户独立确认计费重,不回填既有费用确认。
|
|
||||||
CREATE TABLE fl_chargeable_weight_confirmation (
|
|
||||||
id BIGINT NOT NULL COMMENT '确认记录ID',
|
|
||||||
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
|
|
||||||
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
|
|
||||||
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
|
|
||||||
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
|
|
||||||
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
|
|
||||||
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
|
|
||||||
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
|
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
|
|
||||||
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
|
|
||||||
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
|
|
||||||
|
|
||||||
-- 服务 schema:baiyue_file
|
-- 服务 schema:baiyue_file
|
||||||
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
USE baiyue_file;
|
USE baiyue_file;
|
||||||
@@ -4756,7 +4765,8 @@ CREATE TABLE fin_bill_line (
|
|||||||
currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_fin_bill_line_source (source_type,source_id,source_version),
|
UNIQUE KEY uk_fin_bill_line_bill_source (bill_id,source_type,source_id,source_version),
|
||||||
|
KEY idx_fin_bill_line_source (source_type,source_id,source_version),
|
||||||
KEY idx_fin_bill_line_bill (tenant_id,bill_id),
|
KEY idx_fin_bill_line_bill (tenant_id,bill_id),
|
||||||
CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)),
|
CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)),
|
||||||
CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')),
|
CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')),
|
||||||
@@ -4870,7 +4880,7 @@ CREATE TABLE fin_document_sequence (
|
|||||||
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
|
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE fin_fee_source (
|
CREATE TABLE fin_fee_source (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
@@ -4997,7 +5007,7 @@ CREATE TABLE fin_receipt_bill (
|
|||||||
CREATE TABLE fin_recharge_request (
|
CREATE TABLE fin_recharge_request (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
||||||
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
|
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
|
||||||
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
|
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
|
||||||
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
|
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
|
||||||
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
|
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
|
||||||
@@ -5037,6 +5047,7 @@ CREATE TABLE fin_settlement (
|
|||||||
due_date date NOT NULL COMMENT '结算到期日期',
|
due_date date NOT NULL COMMENT '结算到期日期',
|
||||||
payment_status varchar(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
|
payment_status varchar(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
|
||||||
status varchar(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
|
status varchar(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
|
||||||
|
active_receivable_statement_id bigint unsigned GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留',
|
||||||
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
|
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
|
||||||
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
|
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
|
||||||
updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID',
|
updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID',
|
||||||
@@ -5044,7 +5055,7 @@ CREATE TABLE fin_settlement (
|
|||||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_fin_settlement_no (tenant_id,settlement_no),
|
UNIQUE KEY uk_fin_settlement_no (tenant_id,settlement_no),
|
||||||
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id,reconciliation_statement_id,direction),
|
UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id,active_receivable_statement_id),
|
||||||
KEY idx_fin_settlement_customer_direction_status (tenant_id,direction,status),
|
KEY idx_fin_settlement_customer_direction_status (tenant_id,direction,status),
|
||||||
KEY idx_fin_settlement_supplier_status (supplier_id,direction,status),
|
KEY idx_fin_settlement_supplier_status (supplier_id,direction,status),
|
||||||
KEY idx_fin_settlement_receivable_statistics (direction,currency_code,generated_date,status,tenant_id),
|
KEY idx_fin_settlement_receivable_statistics (direction,currency_code,generated_date,status,tenant_id),
|
||||||
@@ -5248,7 +5259,7 @@ CREATE TABLE fin_wallet_hold (
|
|||||||
CREATE TABLE fin_wallet_ledger (
|
CREATE TABLE fin_wallet_ledger (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
||||||
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
|
||||||
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||||
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||||
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
|
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||||
@@ -6040,9 +6051,48 @@ CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_book
|
|||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
|
|
||||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN
|
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||||
IF OLD.status = 'RELEASED' THEN
|
BEGIN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
|
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
|
||||||
|
IF OLD.status = 'RELEASED' AND NOT (
|
||||||
|
(NEW.id <=> OLD.id)
|
||||||
|
AND (NEW.booking_no <=> OLD.booking_no)
|
||||||
|
AND (NEW.status <=> OLD.status)
|
||||||
|
AND (NEW.customer_id <=> OLD.customer_id)
|
||||||
|
AND (NEW.customer_name <=> OLD.customer_name)
|
||||||
|
AND (NEW.channel_code <=> OLD.channel_code)
|
||||||
|
AND (NEW.channel_name <=> OLD.channel_name)
|
||||||
|
AND (NEW.warehouse_id <=> OLD.warehouse_id)
|
||||||
|
AND (NEW.warehouse_name <=> OLD.warehouse_name)
|
||||||
|
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
|
||||||
|
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
|
||||||
|
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
|
||||||
|
AND (NEW.carrier_code <=> OLD.carrier_code)
|
||||||
|
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
|
||||||
|
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
|
||||||
|
AND (NEW.vessel_name <=> OLD.vessel_name)
|
||||||
|
AND (NEW.voyage_no <=> OLD.voyage_no)
|
||||||
|
AND (NEW.departure_port_code <=> OLD.departure_port_code)
|
||||||
|
AND (NEW.departure_port_name <=> OLD.departure_port_name)
|
||||||
|
AND (NEW.destination_port_code <=> OLD.destination_port_code)
|
||||||
|
AND (NEW.destination_port_name <=> OLD.destination_port_name)
|
||||||
|
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
|
||||||
|
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
|
||||||
|
AND (NEW.remark <=> OLD.remark)
|
||||||
|
AND (NEW.freight_term <=> OLD.freight_term)
|
||||||
|
AND (NEW.service_term <=> OLD.service_term)
|
||||||
|
AND (NEW.package_unit <=> OLD.package_unit)
|
||||||
|
AND (NEW.cargo_description <=> OLD.cargo_description)
|
||||||
|
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
|
||||||
|
AND (NEW.declared_package_count <=> OLD.declared_package_count)
|
||||||
|
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
|
||||||
|
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
|
||||||
|
AND (NEW.created_by <=> OLD.created_by)
|
||||||
|
AND (NEW.created_by_name <=> OLD.created_by_name)
|
||||||
|
AND (NEW.created_at <=> OLD.created_at)
|
||||||
|
AND NEW.version = OLD.version + 1
|
||||||
|
) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
|
||||||
END IF;
|
END IF;
|
||||||
IF OLD.status <> NEW.status AND NOT (
|
IF OLD.status <> NEW.status AND NOT (
|
||||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||||
@@ -8020,7 +8070,7 @@ CREATE TABLE fl_sailing_channel (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
|
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
|
||||||
KEY idx_fl_sailing_channel_status (status)
|
KEY idx_fl_sailing_channel_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
CREATE TABLE fl_sailing_template (
|
CREATE TABLE fl_sailing_template (
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
@@ -8056,7 +8106,7 @@ CREATE TABLE fl_sailing_template (
|
|||||||
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
|
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
|
||||||
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
|
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
|
||||||
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
|
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
CREATE TABLE fl_sailing (
|
CREATE TABLE fl_sailing (
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
@@ -8093,7 +8143,7 @@ CREATE TABLE fl_sailing (
|
|||||||
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
|
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
|
||||||
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
|
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
|
||||||
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
|
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
CREATE TABLE fl_sailing_history (
|
CREATE TABLE fl_sailing_history (
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
@@ -8107,7 +8157,7 @@ CREATE TABLE fl_sailing_history (
|
|||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
|
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
USE baiyue_iam;
|
USE baiyue_iam;
|
||||||
|
|
||||||
@@ -8397,4 +8447,38 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
|
|||||||
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 (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',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,5102213,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables;
|
USE baiyue_order;
|
||||||
|
CREATE TABLE ord_document_sequence (
|
||||||
|
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
||||||
|
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
||||||
|
PRIMARY KEY (sequence_key),
|
||||||
|
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务单据每日独立原子流水';
|
||||||
|
|
||||||
|
USE baiyue_warehouse;
|
||||||
|
CREATE TABLE wh_document_sequence (
|
||||||
|
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
||||||
|
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
||||||
|
PRIMARY KEY (sequence_key),
|
||||||
|
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_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));
|
||||||
|
USE baiyue_iam;
|
||||||
|
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
|
||||||
|
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
|
||||||
|
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
|
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
|
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 排车运输接入数据库变更汇总
|
||||||
|
|
||||||
|
执行文件:[lastleg-dispatch-transport-tasks.sql](lastleg-dispatch-transport-tasks.sql)。目标为 **MySQL 8 / baiyue_lastleg**,文件内已有 `USE baiyue_lastleg`。可整份重复执行;按 information_schema 判断字段及索引,按 IF NOT EXISTS 创建表。已在本机既有业务数据环境连续执行两次通过;未在远端环境执行。
|
||||||
|
|
||||||
|
| 对象 | 新增或调整 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| ll_dispatch_order.task_type | 新增 VARCHAR(32) NOT NULL DEFAULT DELIVERY | 区分仓库派送、到仓取货、本土卡车;旧单保留派送类型 |
|
||||||
|
| ll_dispatch_truck_task | 新表;dispatch_no、trip_no、customer_id、order_no、active_order | 真实排车关联原单;运输记录唯一、有效原单唯一;取消释放有效键,完成保留 |
|
||||||
|
| ll_dispatch_transport_create | 新表;trip_no、request_hash | 原安排请求摘要,避免重复建单及同键更换原单 |
|
||||||
|
| ll_dispatch_transport_command | 新表;dispatch_no、request_key、request_hash、evidence_json、created_at | 执行命令幂等、已确认完成凭证持久化 |
|
||||||
|
| ll_dispatch_daily_sequence | 既有正式日流水表,缺失时补建 | 统一 PC 日期与四位流水;不修改历史单号 |
|
||||||
|
| ll_dispatch_order.customer_id | 仅旧库存在且为 NOT NULL 时改为允许 NULL | 兼容整车跨客户主单;保留原值,不回填或置空 |
|
||||||
|
| ll_us_truck_order.contact_name / contact_info / requested_date | 仅旧库仍为 NOT NULL 时改为允许 NULL | 兼容现有原单起终点联系人及取送时间模型,保留历史字段和数据 |
|
||||||
|
| ll_fleet_external_booking.source_looseboard_order_id | 缺失时补 BIGINT NULL | 当前承运占用查询依赖,等同 V33;不创建散板业务 |
|
||||||
|
| ll_fleet_external_booking.idx_booking_looseboard_source | 缺失时补客户、原散板ID、状态联合索引 | 原单承运占用查询 |
|
||||||
|
|
||||||
|
所有新增表、字段均有中文注释。SQL不创建账号、不改角色权限、不删除表或记录、不改库存及历史单号,也不回填旧任务。
|
||||||
|
|
||||||
|
前置:目标库已具备当前整车排车、FleetTrip、仓储转运取货及本土卡车业务结构;本文件是本次运输接入增量,不替代这些模块的完整升级。执行完成后配套发布 Lastleg 和 Admin。迁移系统记录为 V32,承运占用依赖结构为 V33;直接交付执行时使用本份汇总 SQL 即可,不再重复手动执行两个版本文件。
|
||||||
|
|
||||||
|
本机验收中发现的旧字段约束与缺少日流水表已一并纳入。业务数据由正式 Gateway HTTP 创建,结构验证未写业务SQL。
|
||||||
+1423
-1458
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,17 @@ import java.util.Arrays;
|
|||||||
|
|
||||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||||
enum BusinessFileCategory {
|
enum BusinessFileCategory {
|
||||||
|
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_DISPATCH_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),
|
||||||
@@ -78,9 +84,10 @@ enum BusinessFileCategory {
|
|||||||
&& tenantId != 0) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||||
}
|
}
|
||||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE
|
||||||
|
|| this == OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE || this == OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE)
|
||||||
&& tenantId != 0) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
|
||||||
}
|
}
|
||||||
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
||||||
@@ -88,7 +95,7 @@ enum BusinessFileCategory {
|
|||||||
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
|
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
|
||||||
}
|
}
|
||||||
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
|
if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
||||||
}
|
}
|
||||||
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ info:
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: >-
|
description: >-
|
||||||
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
||||||
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||||
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
|
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
|
||||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
|
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
|
||||||
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
||||||
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
||||||
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
||||||
@@ -126,7 +126,7 @@ components:
|
|||||||
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
||||||
DispatchEvidenceType:
|
DispatchEvidenceType:
|
||||||
type: string
|
type: string
|
||||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
|
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE]
|
||||||
MetadataEnvelope:
|
MetadataEnvelope:
|
||||||
type: object
|
type: object
|
||||||
required: [code, data]
|
required: [code, data]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import static org.mockito.Mockito.*;
|
|||||||
|
|
||||||
class DispatchEvidenceFileTest {
|
class DispatchEvidenceFileTest {
|
||||||
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
||||||
|
private static final String EXCEPTION = "OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE";
|
||||||
|
private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE";
|
||||||
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
||||||
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
||||||
@TempDir Path root;
|
@TempDir Path root;
|
||||||
@@ -40,9 +42,9 @@ class DispatchEvidenceFileTest {
|
|||||||
FileObjectEntity.class);
|
FileObjectEntity.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||||
var files = mock(FileObjectMapper.class);
|
var files = mock(FileObjectMapper.class);
|
||||||
var storage = mock(BusinessFileStorage.class);
|
var storage = mock(BusinessFileStorage.class);
|
||||||
@@ -64,6 +66,19 @@ class DispatchEvidenceFileTest {
|
|||||||
verifyNoInteractions(files, storage);
|
verifyNoInteractions(files, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 司机照片与异常图片使用独立平台类别,拒绝PDF和伪造图片。 */
|
||||||
|
@ParameterizedTest @ValueSource(strings = {PHOTO, EXCEPTION})
|
||||||
|
void photoAcceptsOnlyRealImages(String type) throws Exception {
|
||||||
|
var storage=storage();var category=BusinessFileCategory.require(type);
|
||||||
|
for(var format:new String[]{"jpeg","png"}) {
|
||||||
|
var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format));
|
||||||
|
var saved=storage.store(0,"71",upload,category);
|
||||||
|
assertThat(storage.read(saved.objectKey())).isEqualTo(upload.getBytes());
|
||||||
|
}
|
||||||
|
assertThatThrownBy(()->storage.store(0,"71",pdf(),category)).isInstanceOf(BizException.class);
|
||||||
|
assertThatThrownBy(()->storage.store(0,"71",new MockMultipartFile("file","driver.png","image/png",pdf().getBytes()),category)).isInstanceOf(BizException.class);
|
||||||
|
}
|
||||||
|
|
||||||
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
|
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||||
@@ -149,7 +164,7 @@ class DispatchEvidenceFileTest {
|
|||||||
|
|
||||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||||
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
||||||
var files = mock(FileObjectMapper.class);
|
var files = mock(FileObjectMapper.class);
|
||||||
var storage = mock(BusinessFileStorage.class);
|
var storage = mock(BusinessFileStorage.class);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class FleetResourcePhotoCategoryTest {
|
||||||
|
@Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() {
|
||||||
|
for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) {
|
||||||
|
category.validateOwner(0);
|
||||||
|
assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台");
|
||||||
|
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
|
||||||
|
assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024);
|
||||||
|
assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class LooseboardTransportPhotoFileTest {
|
||||||
|
/** 两节点文件独立归属原排车,客户文件及PDF不能充当现场照片。 */
|
||||||
|
@Test void photosHaveDistinctPlatformOwnershipAndImagePolicy(){
|
||||||
|
var pickup=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE");
|
||||||
|
var arrival=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE");
|
||||||
|
assertThat(pickup).isNotEqualTo(arrival);
|
||||||
|
for(var category:new BusinessFileCategory[]{pickup,arrival}){
|
||||||
|
category.validateOwner(0);assertThatThrownBy(()->category.validateOwner(100)).hasMessageContaining("平台");
|
||||||
|
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
|
||||||
|
assertThat(category.storageBusinessId("100")).isEqualTo("100");
|
||||||
|
assertThatThrownBy(()->category.storageBusinessId("../100")).isInstanceOf(RuntimeException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,8 +45,9 @@ class OwnedBusinessPhotoFileTest {
|
|||||||
verifyNoInteractions(files, storage);
|
verifyNoInteractions(files, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 装车驾驶证允许 PDF;油耗仍限图片,两类都拒绝空文件、伪造内容和超限文件。 */
|
||||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||||
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
|
void enforcesLoadingAndFuelFormatPoliciesAndRejectsInvalidFiles(String type) throws Exception {
|
||||||
var storage = new BusinessFileStorage();
|
var storage = new BusinessFileStorage();
|
||||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||||
storage.initializeRoot();
|
storage.initializeRoot();
|
||||||
@@ -60,8 +61,14 @@ class OwnedBusinessPhotoFileTest {
|
|||||||
for (var upload : new MockMultipartFile[]{
|
for (var upload : new MockMultipartFile[]{
|
||||||
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
|
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
|
||||||
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
|
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
|
||||||
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
|
new MockMultipartFile("file", "spoof.pdf", "application/pdf", "not-pdf".getBytes())})
|
||||||
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
|
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
|
||||||
|
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nTEST license".getBytes());
|
||||||
|
if (LOADING.equals(type)) {
|
||||||
|
var saved = storage.store(0, "71", pdf, category);
|
||||||
|
assertThat(saved.contentType()).isEqualTo("application/pdf");
|
||||||
|
assertThat(storage.read(saved.objectKey())).isEqualTo(pdf.getBytes());
|
||||||
|
} else assertThatThrownBy(() -> storage.store(0, "71", pdf, category)).isInstanceOf(BizException.class);
|
||||||
MultipartFile oversized = mock(MultipartFile.class);
|
MultipartFile oversized = mock(MultipartFile.class);
|
||||||
when(oversized.isEmpty()).thenReturn(false);
|
when(oversized.isEmpty()).thenReturn(false);
|
||||||
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
|
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
|
||||||
|
|||||||
+2
-2
@@ -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` |
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
|
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
|
||||||
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
||||||
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
||||||
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
- 财务单据编号遵循 `../../docs/业务单据编号契约.md`,由本服务 Repository/Mapper 按前缀与北京时间日期原子分配;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||||
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
||||||
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
||||||
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
||||||
|
|||||||
+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) { }
|
||||||
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
|
|||||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
||||||
String reviewStatus, String sourceStatus,
|
String reviewStatus, String sourceStatus,
|
||||||
boolean statementGenerated) {}
|
boolean statementGenerated) {}
|
||||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
|
||||||
BigDecimal amount, String method, String referenceNo,
|
BigDecimal amount, String method, String referenceNo,
|
||||||
List<String> voucherFileIds, long createdBy, String createdByName,
|
List<String> voucherFileIds, long createdBy, String createdByName,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.finance.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
|
||||||
|
@Mapper
|
||||||
|
public interface DocumentSequenceMapper {
|
||||||
|
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
|
||||||
|
@Insert("INSERT INTO fin_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
|
||||||
|
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
|
||||||
|
int allocate(@Param("key") String key);
|
||||||
|
|
||||||
|
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
|
||||||
|
@Select("SELECT LAST_INSERT_ID()")
|
||||||
|
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||||
|
Long allocatedValue();
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.baiyue.finance.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
|
||||||
|
@Mapper
|
||||||
|
public interface FinanceFeeSourceReadMapper {
|
||||||
|
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id" +
|
||||||
|
" WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'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 FILTER = " FROM fin_fee_source f WHERE 1=1 " +
|
||||||
|
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
|
||||||
|
" AND f.status<>'SUPERSEDED' AND NOT EXISTS(SELECT 1 FROM fin_fee_source newer" +
|
||||||
|
" WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type" +
|
||||||
|
" AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)" +
|
||||||
|
"</if>" +
|
||||||
|
"<if test='customerId != null'> AND f.customer_id=#{customerId}</if>" +
|
||||||
|
"<if test='direction != null'> AND f.direction=#{direction}</if>" +
|
||||||
|
"<if test='reviewStatus != null'> AND f.review_status=#{reviewStatus}</if>" +
|
||||||
|
"<if test='status != null'> AND " + STATUS + "=#{status}</if>" +
|
||||||
|
"<if test='statementGenerated != null'> AND " + GENERATED + "=#{statementGenerated}</if>" +
|
||||||
|
"<if test='dateFrom != null'> AND " + BUSINESS_DATE + ">=#{dateFrom}</if>" +
|
||||||
|
"<if test='dateTo != null'> AND " + BUSINESS_DATE + "<=#{dateTo}</if>" +
|
||||||
|
"<if test='keyword != null'> AND (f.source_no LIKE CONCAT('%',#{keyword},'%')" +
|
||||||
|
" OR f.fee_item_name LIKE CONCAT('%',#{keyword},'%')" +
|
||||||
|
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||||
|
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||||
|
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||||
|
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',#{keyword},'%'))</if>";
|
||||||
|
|
||||||
|
/** 筛选和计数共用当前版本条件,不能先分页再去重。 */
|
||||||
|
@Select("<script>SELECT COUNT(*)" + FILTER + "</script>")
|
||||||
|
long count(Map<String, Object> filters);
|
||||||
|
|
||||||
|
/** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */
|
||||||
|
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS + " effective_status" + FILTER +
|
||||||
|
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||||
|
List<Map<String, Object>> list(Map<String, Object> filters);
|
||||||
|
|
||||||
|
/** 详情与列表共用状态投影,仍允许通过精确 ID 查看历史版本。 */
|
||||||
|
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS +
|
||||||
|
" effective_status FROM fin_fee_source f WHERE f.id=#{id}</script>")
|
||||||
|
// 同事务的审核/调整仍含 JDBC 写入,读取必须清空 MyBatis 会话缓存才能返回变更后状态。
|
||||||
|
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||||
|
Map<String, Object> detail(@Param("id") long id);
|
||||||
|
|
||||||
|
String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" +
|
||||||
|
" AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" +
|
||||||
|
" AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" +
|
||||||
|
" AND f.source_fee_version<=anchor.source_fee_version";
|
||||||
|
|
||||||
|
/** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */
|
||||||
|
@Select("<script>SELECT f.id,f.source_fee_version,f.amount,f.currency_code,f.created_at" + LINEAGE +
|
||||||
|
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId}" +
|
||||||
|
" AND f.source_system IN ('FIRSTLEG','LASTLEG') ORDER BY f.source_fee_version,f.id</script>")
|
||||||
|
List<Map<String, Object>> versions(@Param("customerId") long customerId, @Param("id") long id);
|
||||||
|
|
||||||
|
/** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */
|
||||||
|
@Select("<script>SELECT a.id,a.created_at,a.action,a.operator_name_snapshot,a.reason,a.before_data,a.after_data" + LINEAGE +
|
||||||
|
" JOIN fin_audit_log a ON a.tenant_id=f.customer_id AND a.resource_type='FIN_FEE_SOURCE' AND a.resource_id=f.id" +
|
||||||
|
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId} ORDER BY a.created_at DESC,a.id DESC</script>")
|
||||||
|
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("id") long id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.baiyue.finance.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */
|
||||||
|
@Mapper
|
||||||
|
public interface FinanceSettlementDetailMapper {
|
||||||
|
String SUMMARY_SELECT =
|
||||||
|
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
||||||
|
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
||||||
|
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
||||||
|
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
||||||
|
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
||||||
|
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
||||||
|
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
||||||
|
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
||||||
|
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
||||||
|
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
||||||
|
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||||
|
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
||||||
|
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
||||||
|
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
||||||
|
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
||||||
|
/** 读取平台结算主单及来源摘要。 */
|
||||||
|
@Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}")
|
||||||
|
List<Map<String, Object>> summary(@Param("settlementId") long settlementId);
|
||||||
|
/** 读取该客户该结算的费用明细。 */
|
||||||
|
@Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl 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.id")
|
||||||
|
List<Map<String, Object>> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||||
|
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
||||||
|
@Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id")
|
||||||
|
List<Map<String, Object>> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||||
|
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
||||||
|
@Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id")
|
||||||
|
List<Map<String, Object>> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId);
|
||||||
|
/** 读取该客户结算修订历史。 */
|
||||||
|
@Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id")
|
||||||
|
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.finance.repository;
|
||||||
|
|
||||||
|
import com.baiyue.finance.mapper.DocumentSequenceMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
|
||||||
|
@Repository
|
||||||
|
public class DocumentSequenceRepository {
|
||||||
|
private final DocumentSequenceMapper mapper;
|
||||||
|
|
||||||
|
/** 只访问所属服务的编号表。 */
|
||||||
|
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
|
||||||
|
|
||||||
|
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public long next(String key) {
|
||||||
|
mapper.allocate(key);
|
||||||
|
Long value = mapper.allocatedValue();
|
||||||
|
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package com.baiyue.finance.repository;
|
||||||
|
|
||||||
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||||
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||||
|
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||||
|
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
||||||
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 费用当前投影及操作历史;版本记录只读,不覆盖账单引用的不可变快照。 */
|
||||||
|
@Repository
|
||||||
|
public class FinanceFeeSourceReadRepository {
|
||||||
|
private final FinanceFeeSourceReadMapper mapper;
|
||||||
|
private final ObjectMapper json;
|
||||||
|
|
||||||
|
/** 注入正式持久化入口与费用快照解析器。 */
|
||||||
|
public FinanceFeeSourceReadRepository(FinanceFeeSourceReadMapper mapper, ObjectMapper json) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.json = json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组装统一查询参数,让计数和分页使用相同来源身份与筛选条件。 */
|
||||||
|
public Map<String, Object> filters(Long customerId, String direction, String status, String reviewStatus,
|
||||||
|
Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) {
|
||||||
|
Map<String, Object> values = new HashMap<>();
|
||||||
|
values.put("customerId", customerId); values.put("direction", direction); values.put("status", status);
|
||||||
|
values.put("reviewStatus", reviewStatus); values.put("statementGenerated", generated);
|
||||||
|
values.put("dateFrom", from); values.put("dateTo", to); values.put("keyword", keyword);
|
||||||
|
values.put("limit", size); values.put("offset", (page - 1) * size);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计数只包含所筛选的当前费用,历史状态专项查询除外。 */
|
||||||
|
public long count(Map<String, Object> filters) { return mapper.count(filters); }
|
||||||
|
|
||||||
|
/** 当前费用映射沿用原币、数量、客户等正式字段。 */
|
||||||
|
public List<FeeSourceSummary> list(Map<String, Object> filters) {
|
||||||
|
return mapper.list(filters).stream().map(this::summary).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 精确版本详情使用与列表相同的金额和状态,并保留审核信息及操作记录。 */
|
||||||
|
public FeeSourceDetail detail(long id) {
|
||||||
|
Map<String, Object> raw = mapper.detail(id);
|
||||||
|
if (raw == null || raw.isEmpty()) throw new FinanceEventException("FIN-FEE-404", "费用来源不存在");
|
||||||
|
Map<String, Object> row = row(raw);
|
||||||
|
FeeSourceSummary s = summary(row);
|
||||||
|
return new FeeSourceDetail(s.feeSourceId(), s.sourceSystem(), s.sourceType(), s.sourceBusinessId(), s.sourceFeeId(),
|
||||||
|
s.sourceVersion(), s.direction(), s.customerId(), s.customerName(), s.supplierId(), s.supplierName(), s.orderId(),
|
||||||
|
s.inboundId(), s.inboundCode(), s.containerId(), s.containerNo(), s.businessDate(), s.feeItemName(),
|
||||||
|
s.quantity(), s.quantityUnit(), s.unitPrice(), s.periodStart(), s.periodEnd(), s.amount(), s.currency(),
|
||||||
|
s.originalCurrency(), s.originalAmount(), s.exchangeRate(), s.exchangeRateMonth(), s.reviewStatus(),
|
||||||
|
s.sourceStatus(), s.statementGenerated(), text(row.get("review_reason")), text(row.get("source_hash")),
|
||||||
|
((Number) row.get("operation_version")).intValue(), logs(s.customerId(), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
|
||||||
|
public List<OperationLog> logs(long customerId, long id) {
|
||||||
|
List<OperationLog> result = new ArrayList<>();
|
||||||
|
Map<String, Object> previous = null;
|
||||||
|
for (Map<String, Object> raw : mapper.versions(customerId, id)) {
|
||||||
|
Map<String, Object> row = row(raw);
|
||||||
|
String version = "来源版本 " + row.get("source_fee_version");
|
||||||
|
String amount = amount(row) + " " + row.get("currency_code");
|
||||||
|
// 同步时间与原业务操作者不能混用,原业务审批仍以来源系统记录为准。
|
||||||
|
result.add(new OperationLog(time(row.get("created_at")),
|
||||||
|
previous == null ? "FEE_SOURCE_PUBLISHED" : "FEE_SOURCE_VERSION_ADJUST",
|
||||||
|
"系统同步", "SUCCESS", previous == null ? version + ";金额 " + amount
|
||||||
|
: "来源版本 " + previous.get("source_fee_version") + " → " + row.get("source_fee_version")
|
||||||
|
+ ";金额 " + amount(previous) + " → " + amount));
|
||||||
|
previous = row;
|
||||||
|
}
|
||||||
|
// 金额差分仍按版本升序计算;展示先反转版本,同毫秒时稳定排序保留新版本在前。
|
||||||
|
java.util.Collections.reverse(result);
|
||||||
|
for (Map<String, Object> raw : mapper.audits(customerId, id)) {
|
||||||
|
Map<String, Object> row = row(raw);
|
||||||
|
String remark = text(row.get("reason"));
|
||||||
|
JsonNode before = parse(row.get("before_data"));
|
||||||
|
JsonNode after = parse(row.get("after_data"));
|
||||||
|
if (before.hasNonNull("amount") && after.hasNonNull("amount")
|
||||||
|
&& before.path("amount").decimalValue().compareTo(after.path("amount").decimalValue()) != 0) {
|
||||||
|
remark = (remark == null ? "" : remark + ";") + "金额 " + before.path("amount").asText()
|
||||||
|
+ " → " + after.path("amount").asText() + " " + after.path("currency").asText("CNY");
|
||||||
|
}
|
||||||
|
result.add(new OperationLog(time(row.get("created_at")), text(row.get("action")),
|
||||||
|
text(row.get("operator_name_snapshot")), "SUCCESS", remark));
|
||||||
|
}
|
||||||
|
// 时间排序保持同毫秒版本的新到旧顺序,以及 Mapper 返回的审计 ID 倒序。
|
||||||
|
result.sort(Comparator.comparing(OperationLog::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将持久化快照转换为费用列表 DTO,不从其他客户或来源补齐业务事实。 */
|
||||||
|
private FeeSourceSummary summary(Map<String, Object> raw) {
|
||||||
|
Map<String, Object> row = row(raw);
|
||||||
|
JsonNode snapshot = parse(row.get("source_snapshot"));
|
||||||
|
BigDecimal amount = decimal(row.get("amount"));
|
||||||
|
BigDecimal originalAmount = first(decimal(text(snapshot, "originalAmount")), amount);
|
||||||
|
BigDecimal quantity = decimal(text(snapshot, "chargeableQuantity"));
|
||||||
|
BigDecimal unitPrice = first(decimal(text(snapshot, "unitPrice")), quantity != null && quantity.signum() > 0
|
||||||
|
? originalAmount.divide(quantity, 6, RoundingMode.HALF_UP) : null);
|
||||||
|
return new FeeSourceSummary(number(row.get("id")), text(row.get("source_system")), text(row.get("source_type")),
|
||||||
|
first(text(row.get("source_no")), text(snapshot, "sourceBusinessId")), number(row.get("source_fee_id")),
|
||||||
|
((Number) row.get("source_fee_version")).intValue(), text(row.get("direction")), number(row.get("customer_id")),
|
||||||
|
text(snapshot, "customerName"), positiveLong(text(snapshot, "supplierId")), text(snapshot, "supplierName"),
|
||||||
|
nullableLong(row.get("order_id")), nullableLong(row.get("inbound_id")), text(snapshot, "inboundCode"),
|
||||||
|
nullableLong(row.get("container_id")), text(snapshot, "containerNo"),
|
||||||
|
first(date(text(snapshot, "businessDate")), LocalDate.of(1970, 1, 1)), text(row.get("fee_item_name")),
|
||||||
|
quantity, text(snapshot, "quantityUnit"), unitPrice, date(text(snapshot, "periodStart")), date(text(snapshot, "periodEnd")),
|
||||||
|
amount, text(row.get("currency_code")), first(text(snapshot, "originalCurrency"), text(row.get("currency_code"))),
|
||||||
|
originalAmount, first(decimal(text(snapshot, "exchangeRate")), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||||
|
first(text(row.get("review_status")), "DRAFT"), text(row.get("effective_status")),
|
||||||
|
Boolean.TRUE.equals(row.get("statement_generated")) || row.get("statement_generated") instanceof Number value && value.intValue() != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保留不同数据库驱动的列名大小写读取语义。 */
|
||||||
|
private static Map<String, Object> row(Map<String, Object> source) {
|
||||||
|
Map<String, Object> result = new LinkedCaseInsensitiveMap<>(); result.putAll(source); return result;
|
||||||
|
}
|
||||||
|
/** 解析不可变快照,损坏数据失败关闭。 */
|
||||||
|
private JsonNode parse(Object value) {
|
||||||
|
try { return value == null ? json.createObjectNode() : json.readTree(value.toString()); }
|
||||||
|
catch (Exception failure) { throw new FinanceEventException("FIN-DATA-500", "费用来源快照损坏"); }
|
||||||
|
}
|
||||||
|
/** 格式化记录金额,保留定点精度。 */
|
||||||
|
private static String amount(Map<String, Object> row) { return decimal(row.get("amount")).toPlainString(); }
|
||||||
|
/** 读取驱动返回的标准时间。 */
|
||||||
|
private static LocalDateTime time(Object value) { return value instanceof Timestamp time ? time.toLocalDateTime() : (LocalDateTime) value; }
|
||||||
|
/** 读取数字主键,仅用于数据引用。 */
|
||||||
|
private static long number(Object value) { return ((Number) value).longValue(); }
|
||||||
|
/** 保留未关联的可空引用。 */
|
||||||
|
private static Long nullableLong(Object value) { return value == null ? null : number(value); }
|
||||||
|
/** 只接受合法的正整型供应商引用。 */
|
||||||
|
private static Long positiveLong(String value) {
|
||||||
|
try { long parsed = Long.parseLong(value); return parsed > 0 ? parsed : null; }
|
||||||
|
catch (NumberFormatException failure) { return null; }
|
||||||
|
}
|
||||||
|
/** 读取可空金额,不经过浮点转换。 */
|
||||||
|
private static BigDecimal decimal(Object value) { return value == null ? null : new BigDecimal(value.toString()); }
|
||||||
|
/** 读取可空业务日期。 */
|
||||||
|
private static LocalDate date(String value) { return value == null ? null : LocalDate.parse(value); }
|
||||||
|
/** 保持空字段的缺失语义。 */
|
||||||
|
private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString(); }
|
||||||
|
/** 读取快照字段。 */
|
||||||
|
private static String text(JsonNode node, String field) { return node.path(field).isNull() || node.path(field).isMissingNode() ? null : text(node.path(field).asText()); }
|
||||||
|
/** 优先使用来源字段,其次使用已有正式默认值。 */
|
||||||
|
private static <T> T first(T value, T fallback) { return value == null ? fallback : value; }
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.finance.repository;
|
||||||
|
|
||||||
|
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */
|
||||||
|
@Repository
|
||||||
|
public class FinanceSettlementDetailRepository {
|
||||||
|
private final FinanceSettlementDetailMapper mapper;
|
||||||
|
/** 注入结算详情持久化入口。 */
|
||||||
|
public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; }
|
||||||
|
/** 保留原数据库列名不区分大小写的读取语义。 */
|
||||||
|
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
|
||||||
|
return rows.stream().map(row -> { Map<String, Object> copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList();
|
||||||
|
}
|
||||||
|
/** 读取平台结算主单及来源摘要。 */
|
||||||
|
public List<Map<String, Object>> summary(long settlementId) { return rows(mapper.summary(settlementId)); }
|
||||||
|
/** 读取该客户该结算的费用明细。 */
|
||||||
|
public List<Map<String, Object>> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); }
|
||||||
|
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
||||||
|
public List<Map<String, Object>> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); }
|
||||||
|
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
||||||
|
public List<Map<String, Object>> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); }
|
||||||
|
/** 读取该客户结算修订历史。 */
|
||||||
|
public List<Map<String, Object>> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); }
|
||||||
|
}
|
||||||
+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); }
|
||||||
|
}
|
||||||
+72
-14
@@ -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,12 +141,17 @@ public class BusinessFeeSourceEventService {
|
|||||||
}
|
}
|
||||||
positive(value.sourceFeeId(), "sourceFeeId");
|
positive(value.sourceFeeId(), "sourceFeeId");
|
||||||
positive(value.customerId(), "customerId");
|
positive(value.customerId(), "customerId");
|
||||||
|
validateAmounts(value);
|
||||||
if (value.walletSettlement() != null) {
|
if (value.walletSettlement() != null) {
|
||||||
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
|
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
|
||||||
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
|
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
|
||||||
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
|
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
|
||||||
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
|
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
|
||||||
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
|
walletIdentity(value);
|
||||||
|
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())
|
||||||
|
// 新采购主单复用人工收费,只有显式账单结算可入池;旧采购和余额费用仍必须引用实扣。
|
||||||
|
&& !("LASTLEG".equals(value.sourceSystem()) && "OVERSEAS_PROCUREMENT".equals(value.businessType())
|
||||||
|
&& "BILL".equals(value.settlementMode()))) {
|
||||||
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
|
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
|
||||||
}
|
}
|
||||||
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
|
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
|
||||||
@@ -148,13 +164,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 +187,31 @@ public class BusinessFeeSourceEventService {
|
|||||||
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
|
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 使用来源冻结汇率复核人民币金额,与海外生产端的分位四舍五入口径一致。 */
|
||||||
|
private void validateAmounts(BusinessFeeSourceChangedV1 value) {
|
||||||
|
try {
|
||||||
|
// 不在接收端静默修正金额或截断汇率;无效精度须由生产端重新审核。
|
||||||
|
value.originalAmount().setScale(2, RoundingMode.UNNECESSARY);
|
||||||
|
value.totalAmount().setScale(2, RoundingMode.UNNECESSARY);
|
||||||
|
value.exchangeRate().setScale(6, RoundingMode.UNNECESSARY);
|
||||||
|
} catch (ArithmeticException invalidPrecision) {
|
||||||
|
throw error("FIN-EVT-019", "费用金额或汇率精度无效");
|
||||||
|
}
|
||||||
|
if ("CNY".equals(value.originalCurrency()) && value.exchangeRate().compareTo(BigDecimal.ONE) != 0)
|
||||||
|
throw error("FIN-EVT-019", "人民币原币费用汇率必须为1");
|
||||||
|
BigDecimal expected = value.originalAmount().multiply(value.exchangeRate()).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
if (expected.compareTo(value.totalAmount()) != 0)
|
||||||
|
throw error("FIN-EVT-019", "费用人民币金额与原币金额、汇率不一致");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首次接收应付仍须校验启用供应商与名称快照,依赖不可用时失败关闭。 */
|
||||||
|
private void validateSupplier(BusinessFeeSourceChangedV1 value) {
|
||||||
|
if (!"PAYABLE".equals(value.direction())) return;
|
||||||
|
var supplier = supplierValidator.requireActive(positive(value.supplierId(), "supplierId"), trim(value.supplierCategory()));
|
||||||
|
if (!value.supplierName().equals(supplier.name()))
|
||||||
|
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
|
||||||
|
}
|
||||||
|
|
||||||
private void validateSettlement(BusinessFeeSourceChangedV1 value) {
|
private void validateSettlement(BusinessFeeSourceChangedV1 value) {
|
||||||
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
|
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
|
||||||
if ("ON_ACCOUNT".equals(value.settlementMode())) {
|
if ("ON_ACCOUNT".equals(value.settlementMode())) {
|
||||||
@@ -202,9 +241,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 +258,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 +274,24 @@ 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 (!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
-35
@@ -1,55 +1,34 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.number.DailyDocumentNumber;
|
||||||
|
import com.baiyue.finance.repository.DocumentSequenceRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
||||||
@Component
|
@Component
|
||||||
public class FinanceDocumentNumberGenerator {
|
public class FinanceDocumentNumberGenerator {
|
||||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
private static final Set<String> PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP", "PAY");
|
||||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
private final DocumentSequenceRepository sequences;
|
||||||
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP");
|
|
||||||
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
|
/** 正式运行使用统一业务时区。 */
|
||||||
@Autowired
|
@Autowired
|
||||||
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
|
public FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences) {
|
||||||
this(jdbc, Clock.system(BUSINESS_ZONE));
|
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
|
/** 固定时钟用于跨日及前缀独立性验证。 */
|
||||||
this.jdbc = jdbc;
|
FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
|
||||||
|
this.sequences = sequences;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(propagation = Propagation.MANDATORY)
|
/** 只为本服务已登记的业务类型分配号码。 */
|
||||||
public String next(String prefix) {
|
public String next(String prefix) {
|
||||||
if (!SUPPORTED_PREFIXES.contains(prefix)) {
|
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
|
||||||
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
|
return DailyDocumentNumber.next(prefix, clock, sequences::next);
|
||||||
}
|
|
||||||
LocalDate sequenceDate = LocalDate.now(clock);
|
|
||||||
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
|
||||||
jdbc.update(
|
|
||||||
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
|
|
||||||
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
|
||||||
"current_value=LAST_INSERT_ID(current_value+1)",
|
|
||||||
sequenceKey);
|
|
||||||
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
|
||||||
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
|
|
||||||
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
|
|
||||||
}
|
|
||||||
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
|
||||||
String.format(Locale.ROOT, "%04d", sequence);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-43
@@ -7,13 +7,12 @@ import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
|||||||
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
||||||
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
||||||
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||||
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
|
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
@@ -199,41 +198,16 @@ public class FinanceExportProviderService {
|
|||||||
.map(value -> receivableStatisticsRow(month, value)).toList();
|
.map(value -> receivableStatisticsRow(month, value)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导出与列表使用相同当前版本、状态及筛选,已付款来源不能被可选结算池遗漏。 */
|
||||||
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
|
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
|
||||||
Long customerId = longValue(p, "customerId", false);
|
|
||||||
String direction = text(p, "direction");
|
|
||||||
String status = text(p, "status");
|
|
||||||
String reviewStatus = text(p, "reviewStatus");
|
|
||||||
Boolean generated = bool(p, "statementGenerated");
|
|
||||||
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
|
|
||||||
String keyword = lower(text(p, "keyword"));
|
|
||||||
List<Map<String, Object>> rows = new ArrayList<>();
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
for (FeePoolItem item : queryService.feePool(customerId, direction)) {
|
for (int page = 1; ; page++) {
|
||||||
FeeSourceDetail detail = detail(item);
|
var result = operationService.listFinanceFeeSources(page, 200, longValue(p, "customerId", false),
|
||||||
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
|
text(p, "direction"), text(p, "status"), text(p, "reviewStatus"), bool(p, "statementGenerated"),
|
||||||
String actualReview = detail == null ? null : detail.reviewStatus();
|
date(p, "businessDateFrom"), date(p, "businessDateTo"), text(p, "keyword"));
|
||||||
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
|
result.data().forEach(source -> rows.add(feeRow(source)));
|
||||||
LocalDate businessDate = detail == null || detail.businessDate() == null
|
if ((long) page * 200 >= result.page().total()) return rows;
|
||||||
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
|
|
||||||
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
|
|
||||||
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
|
|
||||||
if (generated != null && !generated.equals(actualGenerated)) continue;
|
|
||||||
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
|
|
||||||
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
|
|
||||||
String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()),
|
|
||||||
detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo())));
|
|
||||||
if (keyword != null && !searchable.contains(keyword)) continue;
|
|
||||||
rows.add(feeRow(item, detail, businessDate));
|
|
||||||
}
|
}
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
private FeeSourceDetail detail(FeePoolItem item) {
|
|
||||||
if ("MANUAL_COST".equals(item.sourceType())) return null;
|
|
||||||
String[] parts = item.poolItemId().split(":", -1);
|
|
||||||
if (parts.length != 2) return null;
|
|
||||||
try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); }
|
|
||||||
catch (RuntimeException ignored) { return null; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Map<String, Object>> statementRows(Map<String, Object> p) {
|
private List<Map<String, Object>> statementRows(Map<String, Object> p) {
|
||||||
@@ -376,13 +350,12 @@ public class FinanceExportProviderService {
|
|||||||
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
|
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
|
||||||
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
|
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
|
||||||
}
|
}
|
||||||
private Map<String, Object> feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) {
|
private Map<String, Object> feeRow(FeeSourceSummary source) {
|
||||||
return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(),
|
return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(),
|
||||||
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
|
"sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
|
||||||
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
|
"containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
|
||||||
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
|
"quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(),
|
||||||
"reviewStatus", detail == null ? null : detail.reviewStatus(),
|
"reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated());
|
||||||
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
|
|
||||||
}
|
}
|
||||||
private Map<String, Object> statementRow(ReconciliationDetail value) {
|
private Map<String, Object> statementRow(ReconciliationDetail value) {
|
||||||
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
|
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
|
||||||
@@ -390,17 +363,30 @@ public class FinanceExportProviderService {
|
|||||||
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(),
|
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(),
|
||||||
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
|
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
|
||||||
}
|
}
|
||||||
|
/** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */
|
||||||
private Map<String, Object> settlementRow(SettlementSummary value) {
|
private Map<String, Object> settlementRow(SettlementSummary value) {
|
||||||
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
|
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
|
||||||
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
|
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
|
||||||
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", value.status());
|
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", settlementStatusLabel(value.status()));
|
||||||
}
|
}
|
||||||
|
/** 明细导出与列表保持相同的中文结算状态。 */
|
||||||
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
|
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
|
||||||
return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(),
|
return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(),
|
||||||
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
|
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
|
||||||
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
|
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
|
||||||
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
|
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
|
||||||
"outstandingAmount", summary.outstandingAmount(), "status", summary.status());
|
"outstandingAmount", summary.outstandingAmount(), "status", settlementStatusLabel(summary.status()));
|
||||||
|
}
|
||||||
|
/** 转换正式结算状态,未知值保留原值以便排查。 */
|
||||||
|
private String settlementStatusLabel(String status) {
|
||||||
|
if (status == null) return null;
|
||||||
|
return switch (status) {
|
||||||
|
case "ISSUED" -> "已出账";
|
||||||
|
case "PARTIALLY_PAID" -> "部分结清";
|
||||||
|
case "PAID" -> "已结清";
|
||||||
|
case "VOIDED" -> "已作废";
|
||||||
|
default -> status;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
|
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
|
||||||
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
|
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
|
||||||
|
|||||||
+23
-85
@@ -1,10 +1,10 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||||
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.FeeSourceDetail;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
||||||
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
|||||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||||
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 com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import feign.FeignException;
|
import feign.FeignException;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -43,19 +44,19 @@ public class FinancePhase1OperationService {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FirstlegFeeSourceClient firstleg;
|
private final FirstlegFeeSourceClient firstleg;
|
||||||
private final String internalToken;
|
private final String internalToken;
|
||||||
|
private final FinanceFeeSourceReadRepository feeSources;
|
||||||
|
|
||||||
|
/** 注入费用来源查询及现有业务依赖。 */
|
||||||
@Autowired
|
@Autowired
|
||||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
FirstlegFeeSourceClient firstleg,
|
FirstlegFeeSourceClient firstleg,
|
||||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
@Value("${baiyue.security.internal-service-token}") String internalToken,
|
||||||
|
FinanceFeeSourceReadRepository feeSources) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.firstleg = firstleg;
|
this.firstleg = firstleg;
|
||||||
this.internalToken = internalToken;
|
this.internalToken = internalToken;
|
||||||
}
|
this.feeSources = feeSources;
|
||||||
|
|
||||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
|
||||||
this(jdbc, objectMapper, null, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
|
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
|
||||||
@@ -162,6 +163,7 @@ public class FinancePhase1OperationService {
|
|||||||
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */
|
||||||
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
||||||
String direction, String sourceStatus,
|
String direction, String sourceStatus,
|
||||||
String reviewStatus,
|
String reviewStatus,
|
||||||
@@ -176,42 +178,15 @@ 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"), "费用来源状态无效");
|
||||||
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
|
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
|
||||||
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
|
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
|
||||||
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
|
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
|
||||||
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
|
|
||||||
List<Object> args = new ArrayList<>();
|
|
||||||
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
|
|
||||||
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
|
|
||||||
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
|
|
||||||
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
|
|
||||||
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
|
|
||||||
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
|
|
||||||
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
|
|
||||||
String normalizedKeyword = blankToNull(keyword);
|
|
||||||
if (normalizedKeyword != null) {
|
|
||||||
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
|
|
||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
|
|
||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
|
|
||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
|
|
||||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
|
|
||||||
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
|
|
||||||
}
|
|
||||||
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
|
|
||||||
List<Object> queryArgs = new ArrayList<>(args);
|
|
||||||
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
|
|
||||||
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
|
|
||||||
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
|
|
||||||
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
|
|
||||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -248,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", "只有草稿或已驳回费用可编辑");
|
||||||
@@ -325,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=?",
|
||||||
@@ -338,52 +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"), logs(rs.getLong("customer_id"), id));
|
|
||||||
}
|
|
||||||
|
|
||||||
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
|
|
||||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
|
||||||
boolean generated = rs.getBoolean("statement_generated");
|
|
||||||
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
|
|
||||||
BigDecimal amount = money(rs, "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 FeeSourceSummary(rs.getLong("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"),
|
|
||||||
rs.getString("effective_status"), generated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<OperationLog> logs(long customerId, long id) {
|
|
||||||
return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) ->
|
|
||||||
new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, 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));
|
||||||
@@ -437,7 +368,14 @@ public class FinancePhase1OperationService {
|
|||||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
||||||
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
||||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
||||||
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
|
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||||
|
private String hash(Object value) {
|
||||||
|
try {
|
||||||
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(value);
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
|
||||||
|
}
|
||||||
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
||||||
private static Long jsonLong(JsonNode node, String field) {
|
private static Long jsonLong(JsonNode node, String field) {
|
||||||
JsonNode value = node.path(field);
|
JsonNode value = node.path(field);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
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 com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
@@ -306,7 +307,14 @@ public class FinanceService {
|
|||||||
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
|
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
||||||
|
private String hash(Object request) {
|
||||||
|
try {
|
||||||
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(request == null ? List.of() : request);
|
||||||
|
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
|
||||||
|
}
|
||||||
|
|
||||||
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
||||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
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 com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
@@ -44,44 +45,34 @@ import java.util.UUID;
|
|||||||
public class FinanceSettlementService {
|
public class FinanceSettlementService {
|
||||||
private static final String CNY = "CNY";
|
private static final String CNY = "CNY";
|
||||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||||
private static final String SETTLEMENT_SUMMARY_SELECT =
|
private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
|
||||||
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
|
||||||
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
|
||||||
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
|
||||||
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
|
||||||
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
|
||||||
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
|
||||||
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
|
||||||
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
|
||||||
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
|
||||||
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
|
||||||
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
|
||||||
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
|
||||||
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
|
||||||
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
|
||||||
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
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;
|
||||||
|
|
||||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||||
FinanceSupplierValidator supplierValidator) {
|
|
||||||
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
|
|
||||||
}
|
|
||||||
|
|
||||||
@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, FinanceCustomerNames customerNames,
|
||||||
|
com.baiyue.finance.repository.FinanceSettlementStateRepository states) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
|
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 日流水。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
||||||
CreateReceivableSettlementRequest request) {
|
CreateReceivableSettlementRequest request) {
|
||||||
@@ -101,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(
|
||||||
@@ -160,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")),
|
||||||
@@ -185,33 +171,21 @@ public class FinanceSettlementService {
|
|||||||
return getSettlement(settlementId);
|
return getSettlement(settlementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
|
||||||
public SettlementDetail getSettlement(long settlementId) {
|
public SettlementDetail getSettlement(long settlementId) {
|
||||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||||
Map<String, Object> row = one(
|
List<Map<String, Object>> records = details.summary(settlementId);
|
||||||
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
|
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
|
||||||
"结算单不存在", settlementId);
|
SettlementSummary summary = summary(records.get(0), customerNames.resolver());
|
||||||
SettlementSummary summary = summary(row);
|
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
|
||||||
List<SettlementLine> lines = jdbc.queryForList(
|
|
||||||
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
|
|
||||||
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
|
|
||||||
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
|
|
||||||
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
|
|
||||||
.stream().map(source -> line(source, summary)).toList();
|
.stream().map(source -> line(source, summary)).toList();
|
||||||
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
|
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
||||||
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
|
.stream().map(this::transaction).toList());
|
||||||
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
|
|
||||||
summary.customerId(), settlementId).stream().map(this::transaction).toList());
|
|
||||||
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
||||||
transactions.addAll(jdbc.queryForList(
|
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
|
||||||
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " +
|
.stream().map(this::transaction).toList());
|
||||||
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
|
||||||
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
|
|
||||||
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
|
|
||||||
}
|
}
|
||||||
List<Map<String, Object>> audits = jdbc.queryForList(
|
List<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
|
||||||
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
|
|
||||||
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
|
|
||||||
summary.customerId(), settlementId);
|
|
||||||
List<SettlementRevision> revisions = new ArrayList<>();
|
List<SettlementRevision> revisions = new ArrayList<>();
|
||||||
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
|
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
|
||||||
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
||||||
@@ -232,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
|
||||||
@@ -273,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);
|
||||||
@@ -298,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);
|
||||||
@@ -310,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,
|
||||||
@@ -445,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"),
|
||||||
@@ -479,8 +525,9 @@ public class FinanceSettlementService {
|
|||||||
text(row, "status"), true);
|
text(row, "status"), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
|
||||||
private SettlementTransaction transaction(Map<String, Object> row) {
|
private SettlementTransaction transaction(Map<String, Object> row) {
|
||||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
|
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
|
||||||
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
||||||
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
||||||
text(row, "created_by_name"),
|
text(row, "created_by_name"),
|
||||||
@@ -521,15 +568,18 @@ public class FinanceSettlementService {
|
|||||||
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 结算收付款使用独立 PAY 日流水,重试由原业务幂等记录恢复。 */
|
||||||
private String transactionNo() {
|
private String transactionNo() {
|
||||||
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
|
return documentNumbers.next("PAY");
|
||||||
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try {
|
try {
|
||||||
|
String canonical = objectMapper.writer()
|
||||||
|
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
|
||||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
|
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
throw error("FIN_001", "幂等请求哈希失败");
|
throw error("FIN_001", "幂等请求哈希失败");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
|||||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -694,8 +695,9 @@ public class FinanceWalletService {
|
|||||||
timestamp(rs.getTimestamp("reviewed_at")));
|
timestamp(rs.getTimestamp("reviewed_at")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
||||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||||
}
|
}
|
||||||
private String fileHash(MultipartFile file) {
|
private String fileHash(MultipartFile file) {
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-18
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
|||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
||||||
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 com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
@@ -45,19 +46,19 @@ 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;
|
||||||
|
|
||||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||||
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 日流水,重复请求返回已创建记录。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
||||||
validateCreate(request);
|
validateCreate(request);
|
||||||
@@ -113,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);
|
||||||
@@ -151,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", "仅已出账对账单允许调整");
|
||||||
@@ -175,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) {
|
||||||
@@ -196,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(),
|
||||||
@@ -279,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() {
|
||||||
@@ -489,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", "对账单修订快照损坏");
|
||||||
}
|
}
|
||||||
@@ -523,9 +528,13 @@ public class ReconciliationStatementService {
|
|||||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
try {
|
||||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(value);
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LocalDateTime localTime(Object value) {
|
private static LocalDateTime localTime(Object value) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user