Compare commits

...
36 Commits
Author SHA1 Message Date
ouyangxq 6577b2aa03 test(lastleg): isolate photo table DDL from subsequent rent migrations 2026-10-26 17:25:26 +08:00
ouyangxq ca02ed0c17 merge: retain verified TEST regression fixture fixes 2026-10-26 17:21:43 +08:00
ouyangxq 1928d2e5cf merge: preserve TEST finance and rent changes with fleet photo updates 2026-10-26 17:21:43 +08:00
admin e9ef12f078 Merge pull request 'feat(lastleg): 排车复用车辆司机档案照片并支持装车多图' (#37) from hotfix-gray-bugfix into main 2026-10-26 17:15:07 +08:00
ouyangxq 5eabfa395b feat(lastleg): reuse fleet photos and support multiple loading images 2026-10-26 17:12:36 +08:00
ouyangxq c6bc3ac85b test(lastleg): specify departed scope for database pagination check 2026-10-26 15:54:43 +08:00
admin 5ad599bd66 Merge pull request 'fix(lastleg): 短少确认按实收入库并交接空托盘预排' (#36) from fix/inbound-shortage-confirmation into main 2026-10-26 15:47:29 +08:00
ouyangxq e27881808d fix(lastleg): confirm inbound shortages and reconcile empty preplanned pallets 2026-10-26 15:46:13 +08:00
ouyangxq 9f7cac22b3 test(lastleg): wire storage rent gate in merged picking fixture 2026-10-26 15:38:11 +08:00
liuyihe 3f8a0df433 费用提交 2026-10-26 15:35:14 +08:00
ouyangxq 17cf05c4e1 fix(db): correct overseas initialization table count 2026-10-26 15:29:51 +08:00
admin ba0bf9ebb7 Merge pull request 'fix(lastleg): 排车排除入库结束后未实收的空托盘' (#35) from hotfix-gray-bugfix into main 2026-10-26 15:16:12 +08:00
ouyangxq 8d211acf5b fix(lastleg): exclude unreceived pallets after inbound completion 2026-10-26 15:11:32 +08:00
ouyangxq f37d52aead Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-10-26 13:58:42 +08:00
ouyangxq 98f7d81bb1 优化 2026-10-26 13:58:40 +08:00
liuyihe 294bfb6020 费用提交 2026-10-26 13:42:10 +08:00
liuyihe 761fe3686e 费用提交 2026-10-26 13:41:43 +08:00
admin 5e9cebcf85 Merge pull request 'fix(firstleg): 一件代发待组托允许确认并发送费用' (#34) from test into main 2026-10-26 11:46:35 +08:00
ouyangxq 0fe1d10566 test(firstleg): include palletization in fee confirmation contract 2026-10-26 11:42:42 +08:00
ouyangxq 50687c851d fix(firstleg): allow fee confirmation while awaiting palletization 2026-10-26 11:38:51 +08:00
admin 2ae8e7ce94 Merge pull request 'fix(firstleg): 预约日历目的港名称为空时显示已保存代码' (#33) from test into main 2026-10-26 10:56:45 +08:00
ouyangxq e31dcf80f9 fix(firstleg): display saved port code in appointment calendar 2026-10-26 10:48:05 +08:00
admin 2573bd92cb 灰度晋级:2026-10-23 晚间 baiyue-firstleg
User-authorized fixed-source promotion; deployment and data preservation verified separately.
2026-10-23 21:00:42 +08:00
admin 20b2a7e058 灰度晋级:Firstleg 与 Lastleg 排车发布规则及累计修复
User-authorized fixed-source promotion; deployment and data preservation verified separately.
2026-10-23 13:40:43 +08:00
admin c0cd2c2183 发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23)
Promote the fixed, reviewed TEST source to the existing production release. Runtime deployment remains a separate controlled step.
2026-10-23 10:51:22 +08:00
admin 46d359241f Merge pull request '隔离受限CI的SQL测试进程并修正旧箱唛断言' (#29) from test into main 2026-10-16 18:45:34 +08:00
admin 57fcf295b1 Merge pull request '修复车次合同测试遗漏的预约批次跨文件引用' (#28) from test into main 2026-10-16 18:15:26 +08:00
admin 5950806924 Merge pull request '生产更新:同步已提交的海外仓分析及业务修复(2026-10-16)' (#27) from test into main 2026-10-16 18:02:58 +08:00
admin 4dc3d1752e Merge pull request '发布采购、拼车、预约及匹配端侧修复至灰度' (#26) from test into main 2026-10-12 10:19:13 +08:00
admin f60f44793f Merge pull request '发布已验收的跨客户同仓预约到灰度' (#25) from release/appointment-gray-20261009 into main 2026-10-09 13:34:38 +08:00
admin d4eb0ef023 Merge pull request '发布已通过 TEST 回归的 2026-10-08 批次至灰度' (#24) from test into main 2026-10-08 22:15:42 +08:00
admin 29bbd85ed6 Merge pull request 'Fix gray warehouse-transfer inbound plan source length' (#23) from test into main 2026-10-08 08:49:21 +08:00
admin f010dcc4e0 Merge pull request 'Release internal fleet capacity pool to gray' (#22) from test into main 2026-10-08 08:11:51 +08:00
admin 7151222836 Merge pull request '补齐灰度审计服务真实健康端点' (#21) from test into main 2026-10-07 11:49:22 +08:00
admin 4825f782a3 Merge pull request '修正灰度环境参数化后的部署合同断言' (#20) from test into main 2026-10-07 11:18:31 +08:00
admin a7ca6b98f4 Merge pull request '发布当前测试版本至临时灰度环境' (#19) from test into main 2026-10-07 11:04:53 +08:00
250 changed files with 10839 additions and 505 deletions
@@ -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;
@@ -0,0 +1,22 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class WalletSettlementReferenceTest {
private final ObjectMapper json = new ObjectMapper();
@Test
void oldReferencesRetainExactHashShape() throws Exception {
String original = "{\"commandEventId\":\"c1\",\"holdId\":\"9\",\"ledgerId\":\"72\"}";
var reference = json.readValue(original, WalletSettlementReference.class);
assertThat(json.readTree(json.writeValueAsString(reference))).isEqualTo(json.readTree(original));
}
@Test
void dedicatedFundingIdentitySurvivesRoundTrip() throws Exception {
var reference = new WalletSettlementReference("c1", "9", "72", "STORAGE_RENT", "445");
assertThat(json.readValue(json.writeValueAsString(reference), WalletSettlementReference.class)).isEqualTo(reference);
}
}
@@ -0,0 +1,39 @@
# 海外费用与仓租数据库定义汇总
本次功能的数据库定义已统一到下面两份 SQL,五份重复版本脚本和两份专项汇总 SQL 已移除。仅整理文件,未连接数据库、执行 SQL 或部署。
## 两个统一入口
| 文件 | 相关数据库 | 本次内容 |
|---|---|---|
| [头程数据库统一增量-MySQL8.sql](头程数据库统一增量-MySQL8.sql) | `baiyue_iam` | 海外费用管理菜单及默认平台管理员授权;保留既有财务编辑、审核按钮和授权 |
| [海外区域统一增量-MySQL8.sql](海外区域统一增量-MySQL8.sql) | `baiyue_lastleg` | 资金与仓租四表、费用关联及换标快照、审核人名称、采购退款请求幂等字段与唯一索引 |
两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `CREATE TABLE` 中,菜单及授权使用初始化种子;不追加存量改造、权限版本回填或动态迁移段。目标 schema 必须为空,按文件原有初始化流程执行;不能向已有业务库重放。
本次不再提供这项功能的独立升级脚本。已有库升级必须按实际结构和明确授权另行准备、审阅受控增量,不能把删除重复文件理解为已有库已完成升级。
## 海外表和字段
| 类型 | 表 / 字段 | 用途 |
|---|---|---|
| 新表 | `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)。
+18 -2
View File
@@ -1,9 +1,19 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。 放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
`finance-overseas-fee-hash-width-repair.sql` 用于明确授权的本机开发环境海外费用推送修复,固定目标 `baiyue_finance`。先核对 `fin_inbox_event.payload_hash`、`fin_fee_source.source_hash` 两列;仅将不足71位的长度扩至71位,兼容 `sha256:` 前缀,保留空值约束和字符集排序规则,不更新业务数据。缺列、异常类型或小于64位时停止;已有足够长度则跳过。使用独立连接、遇错停止,执行后回查结构,再通过原费用事件重试验证,禁止重新生成费用或扣款。脚本包含执行后移除的专用校验过程;创建或执行失败须停止,不扩大修复范围。文件准备不表示已执行。
`warehouse-supplier-query-structure-repair.sql` 用于明确授权的本机开发环境供应商查询结构修复,固定目标 `baiyue_warehouse`:仅在缺失时新增 `wh_supplier.trucking_scope`,并按当前模块基线创建缺失的 `wh_supplier_carrier` 空表。结构存在则跳过,不更新供应商类别、费用或资金,不推断历史拖车的国内/海外归属。使用独立连接、遇错停止,执行后回查结构并通过正式供应商列表 API 验证;不需要重启服务。文件准备不表示已获授权或已执行。
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。 仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。 排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
@@ -69,7 +79,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 | | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 238 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -92,7 +102,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/238`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码 ## 平台管理员密码
@@ -192,3 +202,9 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
## 统一业务单号 ## 统一业务单号
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。 编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照、审核人名称,以及采购退款请求幂等字段和唯一索引;同步模块源基线、国内权限种子及海外空库 238 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
@@ -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,16 @@
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
@@ -0,0 +1,9 @@
-- 先于新版本 Lastleg 上线执行。历史订单保持海外采购回国内,不改数量、资金或交寄事实。
SET @procurement_direction_ddl = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'll_overseas_procurement_order' AND COLUMN_NAME = 'procurement_direction') = 0,
'ALTER TABLE ll_overseas_procurement_order ADD COLUMN procurement_direction VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS_TO_DOMESTIC'' COMMENT ''采购方向:海外采购回国内/国内采购发海外''',
'SELECT 1');
PREPARE procurement_direction_stmt FROM @procurement_direction_ddl;
EXECUTE procurement_direction_stmt;
DEALLOCATE PREPARE procurement_direction_stmt;
@@ -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;
+19
View File
@@ -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,73 @@
-- 固定目标本机 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。
-- 使用独立连接、遇错停止;先核对既有表结构,不能重放空库初始化。
USE baiyue_lastleg;
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
AND COLUMN_NAME IN ('amount','original_amount','exchange_rate','created_by','fee_item_code','direction');
-- 上次 CALL 前置检查失败可能遗留本专用过程,仅清理本脚本自己的过程后重建。
DROP PROCEDURE IF EXISTS storage_rent_zero_fee_repair_20261023;
DELIMITER $$
CREATE PROCEDURE storage_rent_zero_fee_repair_20261023()
BEGIN
DECLARE checked_columns INT DEFAULT 0;
DECLARE checked_constraints INT DEFAULT 0;
DECLARE rate_type VARCHAR(64);
DECLARE rate_comment TEXT;
DECLARE rate_default TEXT;
DECLARE rate_default_sql TEXT;
IF DATABASE()<>'baiyue_lastleg' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg';
END IF;
SELECT COUNT(*) INTO checked_columns FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
AND ((COLUMN_NAME IN ('amount','original_amount') AND DATA_TYPE='decimal' AND IS_NULLABLE='NO')
OR (COLUMN_NAME='exchange_rate' AND DATA_TYPE='decimal' AND EXTRA='')
OR (COLUMN_NAME IN ('created_by','fee_item_code','direction') AND DATA_TYPE='varchar' AND IS_NULLABLE='NO'));
SELECT COUNT(*) INTO checked_constraints FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
AND CONSTRAINT_NAME='chk_ll_finance_fee_amount' AND CONSTRAINT_TYPE='CHECK';
IF checked_columns<>6 OR checked_constraints<>1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用源表字段或金额约束异常,停止修复';
END IF;
-- 增加新约束前只读检查;人工费用仍须正额且有效汇率,防止意外放宽。
IF EXISTS (SELECT 1 FROM ll_finance_fee_source
WHERE NOT ((amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE'))
AND ((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')))) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在不满足新约束的费用,停止且不改数据';
END IF;
SELECT COUNT(*) INTO checked_constraints FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
AND CONSTRAINT_NAME='chk_ll_finance_fee_exchange_rate' AND CONSTRAINT_TYPE='CHECK';
IF checked_constraints=0 THEN
-- 保留真实已有列的精度和备注,不把存量 DECIMAL(18,8) 收窄到基线精度。
SELECT COLUMN_TYPE,COLUMN_COMMENT,COLUMN_DEFAULT INTO rate_type,rate_comment,rate_default
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='exchange_rate';
IF rate_type NOT REGEXP '^decimal[(][0-9]+,[0-9]+[)]$' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='汇率列不是预期定点数类型,停止修复';
END IF;
IF LOCATE('自动零元仓租可空',rate_comment)=0 THEN
SET rate_comment=CONCAT(rate_comment,',自动零元仓租可空');
END IF;
-- 保留既有数值默认值(例如1.00000000);拒绝表达式或非数值默认值。
IF rate_default IS NOT NULL AND rate_default NOT REGEXP '^-?[0-9]+([.][0-9]+)?$' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='汇率默认值不是预期数值,停止修复';
END IF;
SET rate_default_sql=IF(rate_default IS NULL,' DEFAULT NULL',CONCAT(' DEFAULT ',rate_default));
-- 单次原子 ALTER 同步调整可空性与两项约束,原币及结算列完全不改。
SET @storage_rent_zero_fee_ddl=CONCAT(
'ALTER TABLE baiyue_lastleg.ll_finance_fee_source DROP CHECK chk_ll_finance_fee_amount, ',
'MODIFY COLUMN exchange_rate ',rate_type,' NULL',rate_default_sql,' COMMENT ',QUOTE(rate_comment),', ',
'ADD CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by=''STORAGE_RENT'' AND fee_item_code=''FEE_TYPE_077'' AND direction=''RECEIVABLE'')), ',
'ADD CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by=''STORAGE_RENT'' AND fee_item_code=''FEE_TYPE_077'' AND direction=''RECEIVABLE''))');
PREPARE storage_rent_zero_fee_stmt FROM @storage_rent_zero_fee_ddl;
EXECUTE storage_rent_zero_fee_stmt;
DEALLOCATE PREPARE storage_rent_zero_fee_stmt;
SET @storage_rent_zero_fee_ddl=NULL;
END IF;
END$$
DELIMITER ;
CALL storage_rent_zero_fee_repair_20261023();
DROP PROCEDURE storage_rent_zero_fee_repair_20261023;
SHOW CREATE TABLE ll_finance_fee_source;
@@ -0,0 +1,40 @@
-- 供应商查询缺失结构修复。MySQL 8;仅用于本次明确授权的本机开发库。
-- 固定目标 baiyue_warehouse;不修改供应商、费用、余额或授信数据。
-- 已有结构安全跳过;不推断历史拖车供应商的国内/海外类别。
-- DDL 会隐式提交,请在独立连接以遇错停止模式整份执行。
USE baiyue_warehouse;
SET NAMES utf8mb4;
SELECT DATABASE() AS target_database;
-- 先确认正式供应商主表存在;不存在时 ALTER 会报错并停止,不创建第二份主档。
SET @supplier_query_column_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_warehouse'
AND table_name = 'wh_supplier' AND column_name = 'trucking_scope'),
'SELECT ''trucking_scope 已存在,跳过'' AS structure_result',
'ALTER TABLE baiyue_warehouse.wh_supplier ADD COLUMN trucking_scope VARCHAR(16) NULL COMMENT ''拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空'' AFTER category'
);
PREPARE supplier_query_statement FROM @supplier_query_column_ddl;
EXECUTE supplier_query_statement;
DEALLOCATE PREPARE supplier_query_statement;
-- 正式列表还会读取船司关联;若未初始化,仅按当前模块基线建立空表。
CREATE TABLE IF NOT EXISTS wh_supplier_carrier (
supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'BOOKING供应商ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
carrier_code VARCHAR(16) NOT NULL COMMENT '船司标准编码',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (supplier_id,carrier_code),
KEY idx_wh_supplier_carrier_code (tenant_id,carrier_code,supplier_id),
CONSTRAINT chk_wh_supplier_carrier_tenant CHECK (tenant_id = 0),
CONSTRAINT chk_wh_supplier_carrier_code CHECK (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BOOKING供应商与标准船司编码多对多关联';
-- 执行后只核对结构,不读取供应商联系信息。
SELECT table_name, column_name, column_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'baiyue_warehouse'
AND ((table_name = 'wh_supplier' AND column_name = 'trucking_scope')
OR table_name = 'wh_supplier_carrier')
ORDER BY table_name, ordinal_position;
@@ -1,4 +1,5 @@
-- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 -- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
-- 海外费用菜单及平台管理员授权统一在本文件 IAM 种子中维护,不再单列本次版本或专项菜单脚本。
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
@@ -8458,4 +8459,8 @@ CREATE TABLE wh_document_sequence (
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0) CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
USE baiyue_iam;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
+137 -4
View File
@@ -1,4 +1,5 @@
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 -- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
-- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
@@ -314,7 +315,7 @@ CREATE TABLE ll_destuff_pallet_cost_allocation (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收体积', actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收体积',
allocated_amount decimal(20,2) NOT NULL COMMENT '客户分摊金额', allocated_amount decimal(20,2) NOT NULL COMMENT '客户分摊金额',
fee_source_id bigint NOT NULL COMMENT '海外费用来源ID', fee_source_id bigint DEFAULT NULL COMMENT '人工审核后关联的海外费用来源ID',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_destuff_customer (cost_id,customer_id), UNIQUE KEY uk_ll_destuff_customer (cost_id,customer_id),
@@ -947,6 +948,7 @@ CREATE TABLE ll_finance_fee_source (
warehouse_id bigint DEFAULT NULL COMMENT '关联海外仓ID', warehouse_id bigint DEFAULT NULL COMMENT '关联海外仓ID',
fee_item_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照', fee_item_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照',
fee_item_name varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照', fee_item_name varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照',
fee_remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用说明,补收或重复作业原因',
direction varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', direction varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
supplier_id bigint DEFAULT NULL COMMENT '应付供应商ID', supplier_id bigint DEFAULT NULL COMMENT '应付供应商ID',
supplier_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照', supplier_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照',
@@ -962,7 +964,7 @@ CREATE TABLE ll_finance_fee_source (
amount decimal(20,6) NOT NULL COMMENT '人民币结算金额', amount decimal(20,6) NOT NULL COMMENT '人民币结算金额',
original_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务原币种,支持CNY或USD', original_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务原币种,支持CNY或USD',
original_amount decimal(20,6) NOT NULL COMMENT '业务原币金额', original_amount decimal(20,6) NOT NULL COMMENT '业务原币金额',
exchange_rate decimal(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1', exchange_rate decimal(20,6) DEFAULT NULL COMMENT '原币折算人民币汇率,人民币固定为1,自动零元仓租可空',
exchange_rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份yyyy-MM', exchange_rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份yyyy-MM',
chargeable_quantity decimal(20,6) DEFAULT NULL COMMENT '计费数量,如箱数、托数、体积或天数', chargeable_quantity decimal(20,6) DEFAULT NULL COMMENT '计费数量,如箱数、托数、体积或天数',
quantity_unit varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费数量单位', quantity_unit varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费数量单位',
@@ -974,6 +976,7 @@ CREATE TABLE ll_finance_fee_source (
source_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', source_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
review_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因', review_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因',
reviewer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', reviewer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
reviewer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人名称快照',
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID', created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID',
updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID', updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID',
@@ -984,7 +987,8 @@ CREATE TABLE ll_finance_fee_source (
KEY idx_ll_finance_fee_customer (customer_id,source_status,created_at), KEY idx_ll_finance_fee_customer (customer_id,source_status,created_at),
KEY idx_ll_finance_fee_business (business_type,business_no,created_at), KEY idx_ll_finance_fee_business (business_type,business_no,created_at),
KEY idx_ll_finance_fee_container_customer (container_booking_id,customer_id,source_status,created_at), KEY idx_ll_finance_fee_container_customer (container_booking_id,customer_id,source_status,created_at),
CONSTRAINT chk_ll_finance_fee_amount CHECK ((amount > 0)), CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')),
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')),
CONSTRAINT chk_ll_finance_fee_currency CHECK ((currency = _utf8mb4'CNY')), CONSTRAINT chk_ll_finance_fee_currency CHECK ((currency = _utf8mb4'CNY')),
CONSTRAINT chk_ll_finance_fee_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), CONSTRAINT chk_ll_finance_fee_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))),
CONSTRAINT chk_ll_finance_fee_original_currency CHECK ((original_currency in (_utf8mb4'CNY',_utf8mb4'USD'))), CONSTRAINT chk_ll_finance_fee_original_currency CHECK ((original_currency in (_utf8mb4'CNY',_utf8mb4'USD'))),
@@ -2122,6 +2126,7 @@ CREATE TABLE ll_overseas_procurement_file (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证';
CREATE TABLE ll_overseas_procurement_order ( CREATE TABLE ll_overseas_procurement_order (
procurement_direction VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS_TO_DOMESTIC' COMMENT '采购方向:海外采购回国内/国内采购发海外',
id bigint NOT NULL COMMENT '主键ID', id bigint NOT NULL COMMENT '主键ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照',
@@ -4593,6 +4598,7 @@ CREATE TABLE ll_return_sku_conversion (
quantity BIGINT NOT NULL COMMENT '实际完成转换件数', quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回', status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回', ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照',
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间', completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间', confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间', reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
@@ -4987,4 +4993,131 @@ CREATE TABLE ll_document_sequence (
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0) CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables; -- 海外费用资金及实收仓租。
-- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。
CREATE TABLE ll_finance_fee_funding (
source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID',
customer_id VARCHAR(128) NOT NULL COMMENT '费用所属客户隔离标识',
collection_mode VARCHAR(16) NOT NULL COMMENT '客户收费方式:BILL账单结算,WALLET余额及授信',
wallet_status VARCHAR(32) NOT NULL COMMENT '资金进度:待冻结、已冻结、待扣款、已扣款、待释放及失败状态',
wallet_business_no VARCHAR(128) NULL COMMENT '本次冻结使用的独立业务编号,重提使用新编号',
command_event_id VARCHAR(64) NULL COMMENT '当前待处理资金指令事件ID',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance确认的资金冻结记录ID',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance确认的已扣资金流水ID',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调递增版本,防止重试重复入账',
error_message VARCHAR(500) NULL COMMENT '最新资金失败原因,成功后清空',
command_history JSON NOT NULL COMMENT '已发资金指令及核验结果哈希,识别重复和错序回执',
created_at DATETIME(3) NOT NULL COMMENT '资金编排创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '资金编排最近变更UTC时间',
PRIMARY KEY (source_fee_id),
KEY idx_ll_fee_funding_customer_state (customer_id, wallet_status),
KEY idx_ll_fee_funding_command (command_event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外人工费用资金冻结、审核扣款及释放编排';
CREATE TABLE IF NOT EXISTS ll_storage_rent_inbound (
receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量',
lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键',
quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数',
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
PRIMARY KEY (receipt_id),
KEY idx_storage_rent_inbound_lot (lot_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记';
CREATE TABLE ll_storage_rent_lot (
id bigint NOT NULL COMMENT '原批次主键',
source_key varchar(80) NOT NULL COMMENT '实收来源幂等键',
customer_id varchar(32) NOT NULL COMMENT '客户标识',
customer_name varchar(255) DEFAULT NULL COMMENT '客户名称',
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
warehouse_name varchar(255) DEFAULT NULL COMMENT '海外仓名称',
business_type varchar(32) NOT NULL COMMENT '原入库业务类型',
business_id bigint NOT NULL COMMENT '原入库业务主键',
business_no varchar(128) NOT NULL COMMENT '原入库业务单号',
order_id bigint DEFAULT NULL COMMENT '尾程订单主键',
inbound_code varchar(128) DEFAULT NULL COMMENT '入仓码',
balance_id bigint DEFAULT NULL COMMENT '普通库存余额主键',
batch_no varchar(128) DEFAULT NULL COMMENT '原实收批次',
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
rule_code varchar(32) NOT NULL COMMENT '仓租规则',
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
created_at datetime(3) NOT NULL COMMENT '创建时间',
updated_at datetime(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_storage_rent_lot_source (source_key),
KEY idx_storage_rent_stock (balance_id,batch_no),
KEY idx_storage_rent_customer (customer_id,warehouse_id,inbound_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实收计租原批次';
CREATE TABLE ll_storage_rent_share (
balance_id bigint DEFAULT NULL COMMENT '普通库存当前余额,退货箱份额为空',
batch_no varchar(128) DEFAULT NULL COMMENT '普通库存当前批次,换标搬移保持原计租批次引用',
id bigint NOT NULL COMMENT '份额主键',
lot_id bigint NOT NULL COMMENT '原计租批次主键',
customer_id varchar(32) NOT NULL COMMENT '客户标识',
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
carton_mark varchar(255) DEFAULT NULL COMMENT '当前箱唛',
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
quantity decimal(24,12) NOT NULL COMMENT '剩余商品数量',
PRIMARY KEY (id),
UNIQUE KEY uk_storage_rent_return_share (lot_id,carton_mark,sku_code),
UNIQUE KEY uk_storage_rent_stock_share (lot_id,balance_id,batch_no),
KEY idx_storage_rent_carton (customer_id,warehouse_id,carton_mark),
KEY idx_storage_rent_share_stock (customer_id,warehouse_id,balance_id,batch_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前库存与原计租批次份额';
CREATE TABLE ll_storage_rent_preparation (
id bigint NOT NULL COMMENT '预校验主键',
warehouse_id varchar(32) NOT NULL COMMENT '作业海外仓',
customer_id varchar(32) NOT NULL COMMENT '费用客户',
mode varchar(32) NOT NULL COMMENT '扫码类型',
execution_no varchar(128) NOT NULL COMMENT '实际作业单号',
request_key varchar(255) NOT NULL COMMENT '原扫码幂等键',
request_hash varchar(64) NOT NULL COMMENT '原扫码内容哈希',
attempt int NOT NULL COMMENT '冻结尝试次数',
business_type varchar(32) NOT NULL COMMENT '本次应收业务类型',
business_id bigint NOT NULL COMMENT '本次应收业务主键',
business_no varchar(128) NOT NULL COMMENT '本次应收业务单号',
status varchar(32) NOT NULL COMMENT '资金与扫码状态',
plan_json json NOT NULL COMMENT '原批次计费不可变快照',
original_amount decimal(24,12) NOT NULL COMMENT '原币仓租金额,币种见计费快照',
amount decimal(24,12) NOT NULL COMMENT '人民币结算金额',
exchange_rate decimal(24,12) NULL COMMENT '冻结确认汇率,免费出库为空',
exchange_rate_month varchar(7) NOT NULL COMMENT '汇率月份',
freeze_event_id varchar(128) DEFAULT NULL COMMENT '冻结指令事件',
settle_event_id varchar(128) DEFAULT NULL COMMENT '扣款指令事件',
release_event_id varchar(128) DEFAULT NULL COMMENT '解冻指令事件',
release_status varchar(32) DEFAULT NULL COMMENT '解冻回执状态,失败后由恢复任务重发',
wallet_version bigint NOT NULL COMMENT '同一预占资金命令版本,重试使用新版本保留审计',
hold_id varchar(128) DEFAULT NULL COMMENT '财务冻结主键',
ledger_id varchar(128) DEFAULT NULL COMMENT '财务扣款流水',
error_message varchar(1000) DEFAULT NULL COMMENT '资金处理结果',
source_fee_id bigint DEFAULT NULL COMMENT '财务费用来源主键',
created_at datetime(3) NOT NULL COMMENT '预校验时间',
consumed_at datetime(3) DEFAULT NULL COMMENT '实际扫描出库时间',
updated_at datetime(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_storage_rent_request (warehouse_id,mode,request_key,attempt),
KEY idx_storage_rent_execution (mode,execution_no,status),
KEY idx_storage_rent_business (customer_id,business_no),
KEY idx_storage_rent_recovery (status,updated_at),
KEY idx_storage_rent_release (release_status,updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照';
CREATE TABLE ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 240 AS initialized_tables;
@@ -7,6 +7,8 @@ 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),
@@ -90,7 +92,7 @@ enum BusinessFileCategory {
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) { if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
} }
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) { if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
} }
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) { if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
@@ -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}
@@ -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);
}
}
}
+1 -1
View File
@@ -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` |
@@ -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()));
}
} }
@@ -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,
@@ -135,6 +135,7 @@ public class BusinessFeeSourceEventService {
positive(value.walletSettlement().holdId(), "walletSettlement.holdId"); positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId"); positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用"); if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
walletIdentity(value);
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) { } else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池"); throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
} }
@@ -154,7 +155,7 @@ public class BusinessFeeSourceEventService {
throw error("FIN-EVT-012", "应付来源供应商快照不一致"); 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", "应收来源不得携带供应商或付款事实");
@@ -202,9 +203,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 +220,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 +236,25 @@ public class BusinessFeeSourceEventService {
feeSourceId); feeSourceId);
} }
/** 独立锁款仅接收已约定的人工费用或仓租身份,不能借此挂接任意业务扣款。 */
private String[] walletIdentity(BusinessFeeSourceChangedV1 source) {
var reference = source.walletSettlement();
String type = reference.walletBusinessType(), no = reference.walletBusinessNo();
if (type == null && no == null) return new String[]{source.businessType(), source.businessNo()};
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
throw error("FIN-EVT-017", "资金业务引用不完整");
if ("OVERSEAS_MANUAL_FEE".equals(type)) {
if ("FEE_TYPE_077".equals(source.feeItemCode())
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
} else if ("STORAGE_RENT".equals(type)) {
if (!"FEE_TYPE_077".equals(source.feeItemCode()))
throw error("FIN-EVT-017", "仓租资金引用与费用项目不匹配");
positive(no, "walletBusinessNo");
} else throw error("FIN-EVT-017", "不支持的独立资金业务引用");
return new String[]{type, no};
}
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) { private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(), positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
@@ -0,0 +1,61 @@
openapi: 3.0.3
info:
title: 平台资金账户关联退款
version: 1.0.0
description: 原消费保持不可变;退款记录关联原消费流水号,累计退款不得超过原消费金额,包含业务退款事件已退金额。
paths:
/api/platform/finance/v1/accounts/{customerId}/ledger/{ledgerId}/refunds:
post:
operationId: refundWalletConsumption
summary: 原消费关联退款
description: 仅平台 tenantId=0 且具有 platform:finance:customer-account:adjust 权限可操作。账户行锁串行校验累计可退金额,无需前端提交账户版本。优先归还已用授信,再增加实际余额。相同键同请求返回原流水;相同键不同原消费或金额失败。非成功消费、跨客户原流水、超额退款均拒绝。
security:
- bearerAuth: []
parameters:
- {name: customerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: ledgerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, reason]
properties:
amount: {type: number, minimum: 0.01, maximum: 999999999999.99, multipleOf: 0.01}
reason: {type: string, minLength: 1, maxLength: 500, description: 必填退款原因,不得仅空白}
responses:
'200':
description: 统一响应 code=200 成功;业务失败使用 FIN-REQ-400、FIN-WALLET-404、FIN-IDEMPOTENCY-CONFLICT 或 FIN-WALLET-REFUND-EXCEEDED 等业务码。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
$ref: '#/components/schemas/RefundLedgerEntry'
'403': {description: 非平台主体或缺少调账权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
RefundLedgerEntry:
type: object
description: 完整返回 FinanceWalletDtos.WalletLedgerEntry,资金余额字段沿用账户流水协议。
properties:
ledgerId: {type: string}
businessNo: {type: string, description: 新退款流水正式 AC 单号}
changeType: {type: string, enum: [REFUND]}
amount: {type: number, description: 实际退款正金额}
sourceType: {type: string, enum: [BUSINESS_REFUND]}
sourceNo: {type: string, description: 客户及幂等键派生的稳定技术退款引用}
referenceNo: {type: string, description: 原消费流水 businessNo,用于关联及累计可退校验}
operatorName: {type: string, nullable: true}
remark: {type: string, description: 退款原因}
actualBalanceAfter: {type: number}
frozenBalanceAfter: {type: number}
availableBalanceAfter: {type: number}
creditUsedAfter: {type: number}
availableCreditAfter: {type: number}
@@ -286,4 +286,82 @@ class BusinessFeeSourceEventServiceTest {
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null, base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
} }
@Test
void manualConsumptionUsesDedicatedFundingIdentityWhilePreservingServiceOrder() {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
}
@Test
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
}
@Test
void dedicatedFundingCannotReferenceAnotherManualFeeOrWrongFeeItem() {
assertThatThrownBy(() -> service.consume(dedicatedEvent("OVERSEAS_MANUAL_FEE", "702-1", "FEE_TYPE_071")))
.hasMessageContaining("来源不匹配");
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_071")))
.hasMessageContaining("项目不匹配");
noFinancialWrites();
}
@Test
void dedicatedConsumptionCannotReuseHoldAlreadyLinkedToAnotherSource() {
stubDedicatedConsumption("STORAGE_RENT", "445");
when(jdbc.queryForList(startsWith("SELECT id FROM fin_fee_source WHERE wallet_hold_id="),
eq(Long.class), eq(9L), eq(901L))).thenReturn(List.of(902L));
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077")))
.hasMessageContaining("已关联其他费用");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
}
@Test
void dedicatedConsumptionRejectsResultFromDifferentFundingBusiness() {
stubDedicatedConsumption("STORAGE_RENT", "445");
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
eq(10001L), eq("STORAGE_RENT"), eq("445"))).thenReturn(List.of(Map.of(
"amount", new BigDecimal("-120.00"), "result_json", """
{"result":"SUCCEEDED","operation":"SETTLE","commandEventId":"command-1",
"walletHoldId":"9","walletLedgerId":"72","customerId":10001,
"businessType":"STORAGE_RENT","businessNo":"446","amount":120.00}
""")));
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077")))
.hasMessageContaining("资金结果不匹配");
}
private void verifyDedicatedConsumption(String type, String no, String item) {
stubDedicatedConsumption(type, no);
EventEnvelope event = dedicatedEvent(type, no, item);
service.consume(event);
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L));
assertThat(event.payload().path("businessType").asText()).isEqualTo("WAREHOUSE_SERVICE");
assertThat(event.payload().path("businessNo").asText()).isEqualTo("PO-US-001");
}
private void stubDedicatedConsumption(String type, String no) {
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L);
ObjectNode result = json.createObjectNode();
result.put("result", "SUCCEEDED").put("operation", "SETTLE").put("commandEventId", "command-1")
.put("walletHoldId", "9").put("walletLedgerId", "72").put("customerId", 10001)
.put("businessType", type).put("businessNo", no).put("amount", new BigDecimal("120.00"));
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
eq(10001L), eq(type), eq(no))).thenReturn(List.of(Map.of(
"amount", new BigDecimal("-120.00"), "result_json", result.toString())));
}
private EventEnvelope dedicatedEvent(String type, String no, String item) {
EventEnvelope base = eventWithoutInbound();
ObjectNode payload = base.payload().deepCopy();
payload.put("feeItemCode", item);
payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9")
.put("ledgerId", "72").put("walletBusinessType", type).put("walletBusinessNo", no);
payload.putNull("action"); payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", "PUBLISHED");
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
} }
@@ -0,0 +1,54 @@
package com.baiyue.finance;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.controller.PlatformFinanceWalletController;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest;
import com.baiyue.finance.service.FinanceWalletService;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 平台退款入口鉴权、必填原因和稳定幂等引用的回归。 */
class PlatformFinanceWalletRefundControllerTest {
private final FinanceWalletService service = mock(FinanceWalletService.class);
private final PlatformFinanceWalletController controller = new PlatformFinanceWalletController(service);
private final WalletRefundRequest request = new WalletRefundRequest(new BigDecimal("12.00"), "退回多收费用");
/** 客户即使携带同名权限也不能借平台入口操作他人资金。 */
@Test void nonPlatformPrincipalsAreDeniedBeforeWalletAccess() {
for (var user : new AuthenticatedUser[]{null, user("CLIENT", 9L), user("PLATFORM", 9L)})
assertThatThrownBy(() -> controller.refund(user, 9, 1, "key", request))
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
verifyNoInteractions(service);
}
/** 同一客户请求键生成同一退款引用,实际防重与金额上限仍由资金事务执行。 */
@Test void authorizedCallsUseStableReferenceAndExistingAdjustPermission() throws Exception {
controller.refund(user("PLATFORM", 0L), 9, 1, " key ", request);
controller.refund(user("PLATFORM", 0L), 9, 1, "key", request);
var reference = org.mockito.ArgumentCaptor.forClass(String.class);
verify(service, times(2)).refundConsumption(eq(9L), eq(1L), reference.capture(), eq("key"),
eq(request.amount()), eq(7L), isNull(), eq(request.reason()));
assertThat(reference.getAllValues()).allSatisfy(value -> assertThat(value).startsWith("MANUAL_REFUND:"));
assertThat(reference.getAllValues().get(0)).isEqualTo(reference.getAllValues().get(1));
assertThat(PlatformFinanceWalletController.class.getMethod("refund", AuthenticatedUser.class,
long.class, long.class, String.class, WalletRefundRequest.class).getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:finance:customer-account:adjust')");
}
/** 空说明或非法幂等键在触达资金前失败。 */
@Test void missingReasonAndIdempotencyAreRejected() {
assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, " ", request)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, "key",
new WalletRefundRequest(BigDecimal.ONE, " "))).isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(service);
}
private AuthenticatedUser user(String app, long tenant) {
return new AuthenticatedUser(7L, tenant, 0L, null, "finance", app, "WEB",
Set.of("platform:finance:customer-account:adjust"));
}
}
@@ -0,0 +1,183 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/** 真实资金服务、隔离数据库及事务验证海外费用释放、实扣与重复操作。 */
class OverseasFeeWalletClosureTest {
private JdbcTemplate jdbc;
private FinanceWalletService wallet;
private TransactionTemplate transaction;
/** 复用正式资金表定义,所有造数只进入本测试独立内存库。 */
@BeforeEach void setup() throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
var sql = Files.readString(Path.of("src/main/resources/db/finance.sql"));
for (var table : List.of("fin_wallet_account", "fin_wallet_hold", "fin_wallet_ledger", "fin_idempotency_record", "fin_audit_log")) {
var matcher = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?\\) ENGINE=[^;]+;").matcher(sql);
assertThat(matcher.find()).as(table).isTrue();
// H2 使用同一列及约束,但去除 MySQL 引擎与更新时间自动生成,避免多列生成键差异。
var ddl = matcher.group().replaceAll("(?s) ENGINE=.*", "")
.replaceAll("(?i) UNSIGNED", "")
.replaceAll("(?i) ON UPDATE CURRENT_TIMESTAMP\\(3\\)", "")
.replaceAll("(?i) DEFAULT CURRENT_TIMESTAMP\\(3\\)", "")
.replaceAll("(?i)((?:created_at|updated_at) DATETIME\\(3\\)) NOT NULL", "$1 NULL");
jdbc.execute(ddl);
}
var numbers = mock(FinanceDocumentNumberGenerator.class);
var sequence = new AtomicInteger();
when(numbers.next(anyString())).thenAnswer(call -> "AC-TEST-" + sequence.incrementAndGet());
wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), numbers, "test", "", "test", "test");
jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(9,'CNY',60,0,160,100,0,0,?,0,0,0)", LocalDate.now().plusDays(7));
}
/** 驳回后释放原冻结,实际余额和授信均恢复可用且不发生消费。 */
@Test void rejectionReleasesCashAndCreditExactlyOnce() {
freeze();
assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("80.00");
assertThat(wallet.account(9).creditFrozen()).isEqualByComparingTo("20.00");
var request = new WalletHoldActionRequest("金额录错,驳回费用");
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
var account = wallet.account(9);
assertThat(account.actualBalance()).isEqualByComparingTo("60.00");
assertThat(account.frozenBalance()).isZero(); assertThat(account.creditFrozen()).isZero();
assertThat(account.availableBalance()).isEqualByComparingTo("160.00");
assertThat(count("CONSUMPTION")).isZero(); assertThat(count("UNFREEZE")).isEqualTo(1);
}
/** 扣款事务失败后可重试原请求,多次点击或不同请求键均不重复消费。 */
@Test void failedSettlementRollsBackAndRetryConsumesOnlyOnce() {
freeze();
var request = new WalletHoldActionRequest("审核通过扣款");
assertThatThrownBy(() -> transaction.execute(status -> {
wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request);
throw new IllegalStateException("模拟扣款事务提交前异常");
})).hasMessageContaining("模拟扣款");
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
assertThat(count("CONSUMPTION")).isZero();
for (int repeat = 0; repeat < 3; repeat++)
transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request));
assertThatThrownBy(() -> transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "another-key", request)))
.hasMessageContaining("已处理");
assertThat(wallet.account(9).actualBalance()).isZero();
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
assertThat(wallet.account(9).frozenBalance()).isZero(); assertThat(count("CONSUMPTION")).isEqualTo(1);
}
/** 第一版错扣退回复用手工充值;原因、关联单号和操作人进入资金流水。 */
@Test void manualCreditRetainsCorrectionReasonAndReplaysWithoutDoubleCredit() {
var request = new AccountAdjustmentRequest("RECHARGE", new BigDecimal("12.00"), LocalDate.now(),
"OTHER", "TR-001 / 费用701", "退回多收操作费", 0);
var first = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
var second = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
assertThat(second.ledgerId()).isEqualTo(first.ledgerId());
assertThat(first.remark()).isEqualTo("退回多收操作费");
assertThat(first.referenceNo()).isEqualTo("TR-001 / 费用701");
assertThat(first.operatorName()).isEqualTo("财务员");
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("72.00");
assertThat(count("RECHARGE")).isEqualTo(1);
}
/** 部分退款优先还授信,累计退款不能超过原消费,原消费事实保持不变。 */
@Test void linkedRefundRestoresCreditThenCashAndCannotExceedOriginalConsumption() {
freeze();
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("审核扣款")));
long original = hold.actionLedgerId();
var first = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
var replay = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
assertThat(replay.ledgerId()).isEqualTo(first.ledgerId());
assertThat(first.changeType()).isEqualTo("REFUND");
assertThat(first.sourceType()).isEqualTo("BUSINESS_REFUND");
assertThat(first.referenceNo()).isEqualTo(jdbc.queryForObject("SELECT business_no FROM fin_wallet_ledger WHERE id=?", String.class, original));
assertThat(wallet.account(9).creditUsed()).isZero();
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("5.00");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", BigDecimal.ONE, 7, "财务", "变更金额"))).hasMessageContaining("幂等");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-too-much", "refund-too-much", new BigDecimal("56"), 7, "财务", "退款"))).hasMessageContaining("超过");
transaction.execute(status -> wallet.refundConsumption(9, original, "refund-2", "refund-key-2", new BigDecimal("55"), 7, "财务", "余款退款"));
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
assertThat(count("REFUND")).isEqualTo(2);
assertThat(count("CONSUMPTION")).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger WHERE id=?", BigDecimal.class, original)).isEqualByComparingTo("-80.00");
}
/** 非消费流水、跨客户原流水及非正金额均不能改变余额。 */
@Test void linkedRefundRejectsWrongCustomerNonConsumptionAndInvalidAmount() {
freeze();
long frozenLedger = jdbc.queryForObject("SELECT id FROM fin_wallet_ledger WHERE change_type='FREEZE'", Long.class);
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, frozenLedger, "bad", "bad", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("成功业务扣款");
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(10,'CNY',0,0,0,0,0,0,0,0,0)");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(10, hold.actionLedgerId(), "wrong-customer", "wrong-customer", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("不存在");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "zero", "zero", BigDecimal.ZERO, 7, "财务", "退款"))).isInstanceOf(RuntimeException.class);
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "blank", "blank", BigDecimal.ONE, 7, "财务", " "))).hasMessageContaining("原因");
assertThat(count("REFUND")).isZero();
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
assertThat(wallet.account(10).actualBalance()).isZero();
}
/** 不同请求键的并发退款同样受原消费可退余额约束。 */
@Test void concurrentRefundsSerializeOnAccountAndNeverOverRefund() throws Exception {
freeze();
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
var start = new java.util.concurrent.CountDownLatch(1);
var executor = java.util.concurrent.Executors.newFixedThreadPool(2);
try {
var results = new java.util.ArrayList<java.util.concurrent.Future<Boolean>>();
for (int i = 0; i < 2; i++) {
final String reference = "concurrent-" + i;
results.add(executor.submit(() -> {
start.await();
try {
transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), reference, reference, new BigDecimal("50"), 7, "财务", "并发退款"));
return true;
} catch (FinanceEventException ex) {
assertThat(ex.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
return false;
}
}));
}
start.countDown();
int succeeded = 0;
for (var result : results) if (result.get(10, java.util.concurrent.TimeUnit.SECONDS)) succeeded++;
assertThat(succeeded).isEqualTo(1);
assertThat(count("REFUND")).isEqualTo(1);
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("30.00");
} finally { executor.shutdownNow(); }
}
/** 冻结始终沿用同一海外费用业务身份。 */
private void freeze() {
transaction.execute(status -> wallet.freeze(9, "freeze", new WalletFreezeRequest(
new BigDecimal("80.00"), "OVERSEAS_MANUAL_FEE", "701-1", "海外费用")));
}
/** 按真实流水类型统计资金影响,避免只检查服务返回状态。 */
private int count(String type) {
return jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=9 AND change_type=?", Integer.class, type);
}
}
@@ -0,0 +1,58 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.SharedFeeCatalogService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** 公共费用目录内部只读入口,禁止借用平台员工令牌跨服务查询。 */
@RestController
@RequestMapping("/internal/firstleg/v1/fee-items")
public class InternalFeeCatalogController {
private final SharedFeeCatalogService service;
private final byte[] expectedToken;
/** 绑定费用目录服务与内部身份凭证,空凭证始终拒绝访问。 */
public InternalFeeCatalogController(SharedFeeCatalogService service,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.service = service;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
/** 返回适用海外仓收付方向的启用项目。 */
@GetMapping
public ApiResponse<List<SharedFeeCatalogService.Item>> list(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam(defaultValue = "RECEIVABLE") String direction) {
requireToken(token);
return ApiResponse.success(service.list(direction));
}
/** 在费用创建时验证实时项目资格。 */
@GetMapping("/{code}")
public ApiResponse<SharedFeeCatalogService.Item> detail(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable String code, @RequestParam String direction) {
requireToken(token);
return ApiResponse.success(service.requireActive(code, direction));
}
/** 使用常量时间比较内部凭证,未配置凭证时关闭入口。 */
private void requireToken(String token) {
if (expectedToken.length == 0 || token == null
|| !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -383,11 +383,12 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getConfirmedAt, now)); .set(InboundFeeEntity::getConfirmedAt, now));
} }
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
public int markInboundConfirmed(long tenantId, long inboundId) { public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>() return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, .in(InboundPricingEntity::getProcessStatus,
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")) List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED") .eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); .set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
} }
@@ -54,6 +54,18 @@ public class FeeItemRepository {
.eq(FeeItemEntity::getFeeCode, feeCode)); .eq(FeeItemEntity::getFeeCode, feeCode));
} }
/** 海外录费仅提供平台启用的国际仓库收付费项目,不复制费用目录。 */
public List<FeeItemEntity> listActiveInternationalWarehouseItems(String direction) {
return feeItems.selectList(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID)
.eq(FeeItemEntity::getStatus, "ACTIVE")
.eq(FeeItemEntity::getTradeScope, "INTERNATIONAL")
.eq("RECEIVABLE".equals(direction), FeeItemEntity::getWarehouseReceivable, true)
.eq("PAYABLE".equals(direction), FeeItemEntity::getWarehousePayable, true)
.orderByAsc(FeeItemEntity::getCategory)
.orderByAsc(FeeItemEntity::getFeeTypeName));
}
public boolean itemNameExists(String tradeScope, String feeTypeName, Long excludedId) { public boolean itemNameExists(String tradeScope, String feeTypeName, Long excludedId) {
return feeItems.selectCount(new LambdaQueryWrapper<FeeItemEntity>() return feeItems.selectCount(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID) .eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID)
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
/** 向海外业务提供公共费用目录的只读切片,项目维护仍归头程主数据。 */
@Service
@RequiredArgsConstructor
public class SharedFeeCatalogService {
private final FeeItemRepository repository;
/** 按收付方向返回当前可使用的费用项目。 */
public List<Item> list(String direction) {
requireDirection(direction);
return repository.listActiveInternationalWarehouseItems(direction).stream()
.map(SharedFeeCatalogService::snapshot).toList();
}
/** 写入业务费用前重新验证项目资格,停用或方向变化不得沿用旧选项。 */
public Item requireActive(String code, String direction) {
requireDirection(direction);
FeeItemEntity item = repository.findItemByCode(code);
if (item == null || !"ACTIVE".equals(item.getStatus())
|| !"INTERNATIONAL".equals(item.getTradeScope())
|| !("RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getWarehouseReceivable())
: Boolean.TRUE.equals(item.getWarehousePayable()))) {
throw new BizException(ResultCode.CONFLICT, "费用项目未启用或不适用于本次收费");
}
return snapshot(item);
}
/** 校验显式收付方向,防止空方向绕过目录过滤。 */
private static void requireDirection(String direction) {
if (!"RECEIVABLE".equals(direction) && !"PAYABLE".equals(direction)) {
throw new BizException(ResultCode.BAD_REQUEST, "请选择费用收付方向");
}
}
/** 返回有限字段,不向其他服务暴露目录实体或审计属性。 */
private static Item snapshot(FeeItemEntity item) {
return new Item(item.getFeeCode(), item.getFeeTypeName(), item.getDefaultPrice());
}
public record Item(String code, String name, BigDecimal defaultPrice) { }
}
@@ -9,11 +9,12 @@ import java.util.List;
@Mapper @Mapper
public interface ContainerCalendarSourceMapper { public interface ContainerCalendarSourceMapper {
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */ /** 按明确来源柜读取到港事实;目的港优先显示名称,未填名称时显示已保存代码。 */
@Select(""" @Select("""
<script> <script>
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo, SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName, c.vessel_name vesselName,c.voyage_no voyageNo,
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
c.destination_warehouse_code destinationWarehouseCode, c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt, COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
@@ -28,10 +29,11 @@ public interface ContainerCalendarSourceMapper {
""") """)
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids); List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */ /** 选出到港、缺日期和可能还柜的有效出仓柜;目的港名称为空时展示代码,最终日期由服务复核。 */
@Select(""" @Select("""
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo, SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName, c.vessel_name vesselName,c.voyage_no voyageNo,
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
c.destination_warehouse_code destinationWarehouseCode, c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt, COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
@@ -40,7 +40,7 @@ paths:
containerNo: {type: string, nullable: true} containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true} vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true} voyageNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true} destinationPortName: {type: string, nullable: true, description: 目的港展示值,优先名称,名称为空时使用已保存的港口代码,两者均为空时返回null}
destinationWarehouseCode: {type: string, nullable: true} destinationWarehouseCode: {type: string, nullable: true}
arrivalAt: {type: string, format: date-time, nullable: true} arrivalAt: {type: string, format: date-time, nullable: true}
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]} arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
@@ -0,0 +1,24 @@
openapi: 3.0.3
info: { title: 公共费用目录内部查询, version: 1.0.0 }
security: [{ internalToken: [] }]
paths:
/internal/firstleg/v1/fee-items:
get:
summary: 查询当前启用的国际海外仓费用项目
parameters:
- { in: query, name: direction, schema: { type: string, enum: [RECEIVABLE, PAYABLE], default: RECEIVABLE } }
responses:
'200': { description: '统一响应data为费用项目数组,项目含code、name、defaultPrice;defaultPrice币种固定CNY,可为空。' }
'403': { description: 内部凭证缺失、错误或服务未配置凭证 }
/internal/firstleg/v1/fee-items/{code}:
get:
summary: 校验录费使用的当前费用项目资格
parameters:
- { in: path, name: code, required: true, schema: { type: string } }
- { in: query, name: direction, required: true, schema: { type: string, enum: [RECEIVABLE, PAYABLE] } }
responses:
'200': { description: 统一响应data为项目code、name、defaultPrice }
'409': { description: 项目不存在、停用或不适用于所选方向 }
components:
securitySchemes:
internalToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
@@ -0,0 +1,107 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.firstleg.mapper.InboundPricingMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 通过真实 Mapper 执行费用状态更新,防止业务层放行但持久化条件遗漏作业状态。 */
class FbaPricingFeeTransitionTest {
private SqlSession session;
private JdbcTemplate sql;
private FbaPricingRepository repository;
/** 每项测试使用独立内存表,保留真实的客户和逻辑删除条件。 */
@BeforeEach
void setUp() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:fee-transition-" + UUID.randomUUID() + ";MODE=MySQL");
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(InboundPricingMapper.class);
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
session.getConnection();
sql = new JdbcTemplate(ds);
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
repository = new FbaPricingRepository();
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
}
/** 关闭最后一个连接后销毁本项测试的内存数据库。 */
@AfterEach
void tearDown() {
if (session != null) session.close();
}
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
@ParameterizedTest
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
insertInbound(processStatus, "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
assertState(processStatus, "CONFIRMED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
}
/** 待组托只补费用确认资格,不能绕过费用快照的前置状态。 */
@ParameterizedTest
@ValueSource(strings = {"UNPRICED", "CONFIRMED", "SENT", "VOIDED"})
void rejectsConfirmationUnlessFeeIsCalculated(String feeStatus) {
insertInbound("WAIT_PALLETIZE", feeStatus);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertState("WAIT_PALLETIZE", feeStatus);
}
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
@Test
void preservesProcessCustomerAndDeletionGuards() {
insertInbound("WAIT_MEASURE", "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertState("WAIT_PALLETIZE", "CALCULATED");
}
/** 模拟发送前作业状态发生变化,旧状态条件须拒绝写入。 */
@Test
void rejectsSendWithStaleProcessStatus() {
insertInbound("WAIT_SPLIT", "CONFIRMED");
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_PALLETIZE")).isZero();
assertState("WAIT_SPLIT", "CONFIRMED");
}
/** 准备单个客户的入库单,不调用共享环境。 */
private void insertInbound(String processStatus, String feeStatus) {
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
processStatus, feeStatus);
}
/** 同时核对物理作业状态与费用状态,防止用改主流程掩盖发送问题。 */
private void assertState(String processStatus, String feeStatus) {
assertThat(sql.queryForObject("SELECT process_status FROM fl_inbound_order WHERE id=88", String.class))
.isEqualTo(processStatus);
assertThat(sql.queryForObject("SELECT fee_status FROM fl_inbound_order WHERE id=88", String.class))
.isEqualTo(feeStatus);
}
}
@@ -194,8 +194,9 @@ class FbaPricingRepositoryRuleListTest {
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET"); .contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
} }
/** 费用确认状态集覆盖待组托及后续履约阶段。 */
@Test @Test
void feeConfirmationAllowsPostLoadingInboundStatuses() { void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
String source; String source;
try { try {
source = Files.readString(Path.of( source = Files.readString(Path.of(
@@ -205,7 +206,7 @@ class FbaPricingRepositoryRuleListTest {
throw new AssertionError(exception); throw new AssertionError(exception);
} }
assertThat(source) assertThat(source)
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")"); .contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
} }
@Test @Test
@@ -22,6 +22,9 @@ import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.R
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
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.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
@@ -223,11 +226,13 @@ class FbaPricingServiceTest {
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
} }
@Test /** 待组托、待分柜确认费用均不推进物流主流程。 */
@DisplayName("待分柜边界确认费用后仍保持主流程状态") @ParameterizedTest
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() { @ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
@DisplayName("待组托或待分柜确认费用后仍保持主流程状态")
void confirmsWithoutRollingBackProcessStatus(String processStatus) {
InboundPricingEntity inbound = inbound("10.000", "1.0000"); InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT"); inbound.setProcessStatus(processStatus);
inbound.setFeeStatus("CALCULATED"); inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED"); InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
@@ -244,7 +249,7 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.confirm( FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-wait-split"); TENANT, OPERATOR, INBOUND, "confirm-wait-split");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); assertThat(response.nextProcessStatus()).isEqualTo(processStatus);
verify(repository).markInboundConfirmed(TENANT, INBOUND); verify(repository).markInboundConfirmed(TENANT, INBOUND);
} }
@@ -436,11 +441,13 @@ class FbaPricingServiceTest {
verify(repository, never()).insertFee(any()); verify(repository, never()).insertFee(any());
} }
@Test /** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
@ParameterizedTest
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@DisplayName("重复发送只返回首次结果且不会重复写Outbox") @DisplayName("重复发送只返回首次结果且不会重复写Outbox")
void sendIsIdempotentAndDoesNotDuplicateOutbox() { void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("FEE_CONFIRMING"); inbound.setProcessStatus(processStatus);
inbound.setFeeStatus("CONFIRMED"); inbound.setFeeStatus("CONFIRMED");
InboundFeeEntity confirmed = savedFee("CONFIRMED"); InboundFeeEntity confirmed = savedFee("CONFIRMED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -448,7 +455,7 @@ class FbaPricingServiceTest {
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once")) when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
.thenReturn(null, completed("FEE_SEND", 501L)); .thenReturn(null, completed("FEE_SEND", 501L));
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
when(repository.markInboundFeeSent(TENANT, INBOUND, "FEE_CONFIRMING")).thenReturn(1); when(repository.markInboundFeeSent(TENANT, INBOUND, processStatus)).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed); when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00")); InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
@@ -459,8 +466,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once"); FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once");
FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once"); FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once");
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET"); assertThat(first.nextProcessStatus()).isEqualTo(expectedStatus);
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET"); assertThat(repeated.nextProcessStatus()).isEqualTo(expectedStatus);
verify(financeOutbox, times(1)).publishInbound( verify(financeOutbox, times(1)).publishInbound(
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND), eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
eq(501L), eq(1), eq(NOW)); eq(501L), eq(1), eq(NOW));
@@ -36,8 +36,9 @@ class MerchantInboundBillingServiceTest {
@MockBean private InboundFeeDetailService feeDetails; @MockBean private InboundFeeDetailService feeDetails;
@Resource private MerchantInboundBillingService service; @Resource private MerchantInboundBillingService service;
/** 测量审核通过并发送费用后,待组托与后续履约阶段均可查看客户费用。 */
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = {"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) @ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) { void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) {
InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT"); InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT");
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class), InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
@@ -84,9 +85,11 @@ class MerchantInboundBillingServiceTest {
verify(orders).findByOrderId(10001L, 77L); verify(orders).findByOrderId(10001L, 77L);
} }
@Test /** 待组托不绕过客户费用可见性的测量审核条件。 */
void shouldHandleCase02() { @ParameterizedTest
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "PENDING", "SENT"); @ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
void shouldHandleCase02(String processStatus) {
InboundOrderEntity inbound = inbound(processStatus, "PENDING", "SENT");
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
assertThatThrownBy(() -> service.find(10001L, 77L)) assertThatThrownBy(() -> service.find(10001L, 77L))
@@ -105,6 +108,19 @@ class MerchantInboundBillingServiceTest {
verify(pricing, never()).latest(20002L, 88L); verify(pricing, never()).latest(20002L, 88L);
} }
/** 客户可在组托前确认已审核并发送的费用,继续使用当前费用版本和幂等键。 */
@Test
void confirmsPublishedFeeWhileWaitingForPalletization() {
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound("WAIT_PALLETIZE", "APPROVED", "SENT"));
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
when(pricing.confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize"))
.thenReturn(fee);
assertThat(service.confirmFee(10001L, 7L, "客户成员", 77L, 3, "confirm-palletize"))
.isSameAs(fee);
verify(pricing).confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize");
}
@Test @Test
void shouldHandleCase04() { void shouldHandleCase04() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT"); InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT");
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.controller.InternalFeeCatalogController;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SharedFeeCatalogServiceTest {
/** 已停用、方向变更或非海外项目不能沿用页面旧选项。 */
@Test void currentEligibilityIsCheckedBeforeUse() {
FeeItemRepository repository = mock(FeeItemRepository.class);
SharedFeeCatalogService service = new SharedFeeCatalogService(repository);
FeeItemEntity item = new FeeItemEntity();
item.setFeeCode("FEE-1"); item.setFeeTypeName("操作费");
item.setStatus("ACTIVE"); item.setTradeScope("INTERNATIONAL");
item.setWarehouseReceivable(true); item.setDefaultPrice(new BigDecimal("2.50"));
when(repository.findItemByCode("FEE-1")).thenReturn(item);
when(repository.listActiveInternationalWarehouseItems("RECEIVABLE")).thenReturn(List.of(item));
assertEquals(new BigDecimal("2.50"), service.list("RECEIVABLE").get(0).defaultPrice());
assertEquals("操作费", service.requireActive("FEE-1", "RECEIVABLE").name());
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "PAYABLE"));
item.setStatus("INACTIVE");
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "RECEIVABLE"));
item.setStatus("ACTIVE"); item.setTradeScope("DOMESTIC");
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "RECEIVABLE"));
assertThrows(BizException.class, () -> service.list(null));
}
/** 内部入口未配置、缺少或错误凭证均不得读取目录。 */
@Test void internalCatalogRequiresExplicitServiceIdentity() {
SharedFeeCatalogService service = mock(SharedFeeCatalogService.class);
InternalFeeCatalogController controller = new InternalFeeCatalogController(service, "configured-token");
assertThrows(BizException.class, () -> controller.list(null, "RECEIVABLE"));
assertThrows(BizException.class, () -> controller.detail("wrong", "FEE-1", "RECEIVABLE"));
assertThrows(BizException.class, () -> new InternalFeeCatalogController(service, "").list("", "RECEIVABLE"));
verifyNoInteractions(service);
when(service.list("RECEIVABLE")).thenReturn(List.of());
assertTrue(controller.list("configured-token", "RECEIVABLE").successful());
}
}
@@ -11,6 +11,41 @@ import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
class ContainerCalendarSourceMapperTest { class ContainerCalendarSourceMapperTest {
/** 两个日历来源入口均优先展示港口名称,仅填代码时仍可回显,缺失时保持空值。 */
@Test
void displaysDestinationNameOrSavedCodeAcrossBothEntrypoints() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:calendar-port-display;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
sql.execute("INSERT INTO fl_container_booking(id,destination_port_name,destination_port_code,record_status) VALUES " +
"(1,'洛杉矶','USLAX','ACTIVE'),(2,NULL,'USLAX','ACTIVE'),(3,' ','USLAX','ACTIVE')," +
"(4,'洛杉矶',NULL,'ACTIVE'),(5,'',' ','ACTIVE')");
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(ContainerCalendarSourceMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var mapper = session.getMapper(ContainerCalendarSourceMapper.class);
var from = LocalDateTime.parse("2026-10-01T00:00:00");
var calendar = mapper.sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
var explicit = mapper.byIds(java.util.List.of(1L, 2L, 3L, 4L, 5L));
var expected = java.util.Arrays.asList("洛杉矶", "USLAX", "USLAX", "洛杉矶", null);
assertEquals(expected, calendar.stream().map(row -> row.destinationPortName()).toList());
for (var row : explicit) {
assertEquals(expected.get(row.containerBookingId().intValue() - 1), row.destinationPortName());
}
assertEquals(5, explicit.size());
}
}
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */ /** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
@Test @Test
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception { void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
@@ -18,7 +53,7 @@ class ContainerCalendarSourceMapperTest {
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1"); ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) { try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," + sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," + "vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)"); "actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)"); sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)"); sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
@@ -65,7 +100,7 @@ class ContainerCalendarSourceMapperTest {
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1"); ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) { try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," + sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," + "vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)"); "actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)"); sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)"); sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
@@ -0,0 +1,50 @@
-- 海外费用查询菜单;编辑与审核沿用独立财务按钮权限,只读授权不包含写操作。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS check_overseas_fee_permission_identity;
DELIMITER $$
CREATE PROCEDURE check_overseas_fee_permission_identity()
BEGIN
-- 先恢复既有财务写按钮和管理员授权,再部署菜单;列表权限不代表可创建或审核。
-- 缺失时先执行 deploy/sql/overseas-fee-canonical-write-permissions-repair.sql。
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)
OR (SELECT COUNT(*) FROM iam_permission p JOIN iam_role_permission rp ON rp.permission_id=p.id
WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_type='BUTTON' AND p.status='ACTIVE' AND p.deleted=0
AND ((p.id=5105024 AND p.permission_code='platform:finance:fee-source:review')
OR (p.id=5105025 AND p.permission_code='platform:finance:fee-source:edit')))<>2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Run overseas-fee-canonical-write-permissions-repair.sql first; fee write grants missing';
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'))) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Overseas fee permission identity conflict';
END IF;
END$$
DELIMITER ;
CALL check_overseas_fee_permission_identity();
DROP PROCEDURE check_overseas_fee_permission_identity;
START TRANSACTION;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=0,status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,5117200,CURRENT_TIMESTAMP(3) FROM iam_role r
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0;
-- 默认管理员获得菜单后,同事务推进授权版本,已有会话及时读取新权限。
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
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
JOIN iam_role r ON r.id=mr.role_id
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.tenant_id=0 AND r.app_code='PLATFORM'
AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
COMMIT;
+13 -3
View File
@@ -36,6 +36,7 @@
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
@@ -57,8 +58,9 @@
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 | | 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购、换标、人工海外费用和仓租通过 `WALLET_COMMAND_REQUESTED` 请求 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;当前 V1 审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。阶段 0A—0E 开发目标另读 `../../docs/财务管理/海外仓费用统一接入阶段0技术契约.md`,收费身份、候选版本、Finance 结果事件与资金计划 V2 尚须以实际代码落地验证,不能因文档存在宣称已实现 | | 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 |
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,部分出库只承担原批次份额,余货不重置库龄。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存,按真实出库部分生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | | 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
@@ -86,7 +88,7 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 - 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。 - 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。 - 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五类装车照片(每类最多5张),无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -161,3 +163,11 @@
- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。 - 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 - 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 - 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
- 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。
### 海外人工收费首版切换
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购保留独立收费流程,禁止进入人工通用收费。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
@@ -264,3 +264,7 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。 - 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。 2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。
### 档案照片只读复用边界(2026-10-26)
`DispatchMasterPhotoService`仅通过`fleet.photo.port.FleetResourcePhotoPort`及其照片DTO读取档案照片,再经排车文件网关生成当趟副本。`TransportTaskService`仅新增照片类别DTO引用,不读取照片Mapper或Repository。`fleet.photo`复用车辆`VehicleRepository`应用端口与司机应用服务校验身份,不反向依赖排车;精确边界纳入依赖合同。
@@ -0,0 +1,167 @@
# 海外费用与仓租首版发布验收
本文件记录本次实现范围及验收口径。源码、本地测试、模拟页面预览、环境部署与实际资金验收分别记录;本地结构补充与重启见文末记录,不能据此认定上线验收通过。
## 首版规则
费用入口为 Admin「海外业务 → 海外费用管理」,仅保留「费用明细」与「仓租库存」两个页签。仓租库存查看相关单据、入库批次、库龄、剩余数量及出库数量记录,不展示金额和审核、调价入口。仓租金额及入库/出库时间、出库批次、计算金额、应扣及实扣金额在费用明细详情查询,多入库批次分别展示实际入库时间。审核与调价统一在对应尾程单据详情的「费用明细」办理,审核前调价必须填写说明。复用 Firstleg 公共费用项目,仓租项目为 `FEE_TYPE_077 / 仓储费`;不建立第二套海外费用目录。人工费用选择真实客户、当前服务业务单、项目、金额与币种后创建,详情保留必要的审核和资金状态。
头程附加费审核后推送 Finance 账单。其余海外人工应收创建时冻结余额及可用授信,审核通过后扣款,Finance 成功扣款回执后登记本地已收费用,直接显示已完成,不再推送财务账单;驳回、取消释放冻结。在途回执、失败重试与同键重放不能重复扣款。车辆或同车运输不能代替每笔费用对应的客户和业务订单。
现有尾程订单没有权威的“原报价已含派送费”字段。首版新增头程派送费仍由审核人员核对原报价,已包含则驳回该费用;系统尚不支持自动判断报价包含关系,审核提示不能替代人工核对。
头程货最终目的地为自有海外仓、仓储转运(含外来货)以及退货建立计租批次。仓租全部使用余额及可用授信。实际 PDA 入库开始、实际 PDA 出库停止本次出库份额计租,每24小时计一天,不足24小时按一天。余货保持原入库时刻,不重置免费期;移库、换标和拆合托不新增免费期。
头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。
| 规则 | 计费阶梯(USD) | 数量口径 |
|---|---|---|
| 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 |
| 退货仓租 | 第1–30天1.5;31–60天2;61–90天3;91天起4 | 原始托数向上取整,按本次出库占原托比例分摊 |
自动计租优先读取现有客户仓租配置;无客户配置时使用上述两张报价表。USD 配置缺少整组中转或退货阶梯时,缺失组补用对应默认报价;非 USD 配置不得直接补用美元价格。已配置阶梯须从第1天连续覆盖至无限期,有限尾阶梯、缺段、负价或不支持的币种均阻断计价,不将配置错误或服务异常当作默认报价。
每次出库资金预校验保存客户、配置编号及版本、币种、完整阶梯和计算结果快照。有效预占和实际扣款使用原快照;修改配置不重算已冻结或已生成的费用。后续新预占按最新客户配置计算本次出库份额的全部库龄,仍保留最初入库时间;本版不把一次出库按历次调价日期再拆段计费。过期或货物、计租天数等依据变化时须重新预校验。
原币金额与人民币结算额分别保留,冻结时固定汇率快照。无有效汇率时不得将美元直接作为人民币扣款。免费期零仓租保留出库记录,不要求汇率、不冻结资金。仓租自动计算,出库仅登记待审核费用并保留冻结;批准后沿原资金身份按实额扣款,驳回释放原冻结。订单费用和海外费用管理均展示原批次实际入库、实际出库、原计算金额、应扣金额及实扣金额。审核前允许原币金额调整且说明必填,保存版本;调低释放多余占用,调高由Finance结算时原子检查差额,余额不足保留原冻结,不登记实扣。原零元改非零先补冻结,成功后才能审核。其余客户收费人工录入并审核。历史已扣费用保留结果,不补审、不重复扣款。
新计租批次在实际入库事务中记录 UTC 时刻,接口携带时区,页面按浏览器时区显示;不猜测历史无时区时间并回填。退货 SKU 换标保存原批次份额迁移快照,撤销时按该快照恢复。批次全部出库后,库龄固定在最后实际出库时刻。
## 计租验收样例
1. 中转批次10箱共2CBM,第10天出4箱:3个收费日 × 0.8CBM × 0.8 = **1.92 USD**;余6箱第31天出库:(23×0.8+1×1) × 1.2CBM = **23.28 USD**。余货仍从最初入库计龄。
2. 退货原批次不足一托,100件按1托,第10天出40件:10×1.5×40%=**6 USD**;第40天出余60件:(30×1.5+10×2)×60%=**39 USD**。第二次出库不再次将剩余货物进位到1托。
3. 入库后恰好24小时为1天,24小时加1毫秒为2天;恰好7天仍免费,7天加1毫秒进入第8天。对金额先按份额精算,完整收费命令汇总后按结算精度取整。
## 发布配套
- 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。
- 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。
- 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。
- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql) 仅解决自动仓租双零金额和空汇率入费用源表,保留人工正额与正常费用有效汇率约束,不更新业务数据。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。
- 新空库:Lastleg 源基线与海外统一初始化脚本同步新表和字段;国内统一基线同步菜单。菜单查询权限 `platform:overseas-fee:list`,编辑与审核分别沿用 `platform:finance:fee-source:edit/review`,仅有查询权限不能写费用。
- 配置:核对 Firstleg、Warehouse 内部服务地址及内部令牌、客户仓租阶梯、有效 USD/CNY 月度汇率、Finance 资金与费用事件直投及回执配置。当前结算支持 USD、CNY,CNY 按1:1结算,其他币种必须先调整为受支持币种。不得在材料中记录密钥。
- 发布顺序:先发布 Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。
## 实际环境验收
以下必须在匹配版本、指定测试客户和仓库上实际操作并保留单号、资金事件、流水和页面证据;本地测试不替代这些步骤。
| 场景 | 通过标准 |
|---|---|
| 权限与查询 | 菜单、客户、业务单、项目、审核及资金状态筛选有效;无权限按钮不展示,直接调用同样被拒绝 |
| 人工海外收费 | 创建只冻结;审核后恰好扣一次;驳回/取消释放;不足余额但授信足够可成功,二者合计不足明确失败 |
| 头程附加费 | 审核前不入账单,批准后回到正确头程来源且不扣钱包;人工核对原报价,已含派送费的新增收费须驳回 |
| 仓租原生入库 | 头程自有仓、外来转运和退货实际扫码均生成正确原批次;非计租范围不误收 |
| 部分及分批出库 | 原托比例、阶梯、库龄和余量符合上例;整托/多箱/混SKU全部来源核对后才能扣库存 |
| 客户价格与默认报价 | 客户专价优先;无配置按两张默认报价;接口超时或无效阶梯不允许默认为免费或默认报价 |
| 调价与币种 | 有效预占及已生成费用不受调价影响,新预占使用新版本;USD、CNY 原币及分币种汇总准确,不将混合币种直接相加 |
| 资金门禁 | 未冻结成功、资金不足、过期、未知网络结果均不放行库存写;成功后仅本次实际出库份额扣款 |
| 幂等与并发 | 同键重放、回执重复/错序、双设备争抢不重复扣款或超出原批次数量;内容改变不得复用原键 |
| 移库换标及异常 | 移库、拆合托、换标保持原库龄;未知历史计租来源不可凭空补零放行 |
| 弃置及取消 | 废弃实际扣库存同样校验仓租;放弃未完成的预占能释放,释放失败保留可处理记录 |
| 资金核对 | 原币、汇率、人民币冻结与实扣金额一致,已收费用关联到正确Finance流水;余额应收不推送账单结算池,重试和回执重放不再次扣款 |
| 页面及设备 | 实际浏览器核对表格、筛选、详情、审核弹窗及错误提示;PDA真实扫码和断网恢复通过 |
## 验证记录
本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调与 PDA 设备验收尚未执行。上线前逐项填写环境、版本、执行时间与证据,未通过项不得标成通过。
2026-10-22 本地验证记录(使用最终受影响文件;复用已通过且输入未变的结果):
| 范围 | 结果与边界 |
|---|---|
| Finance 与事件契约 | Finance 294项:293通过、1项因外部产品契约仓库缺失跳过;公共事件契约37项通过,包含资金业务引用序列化与旧哈希形状验证 |
| Firstleg 费用目录 | 公共费用项目查询2项通过 |
| Lastleg 受影响链路 | 37个测试类汇总578项:576通过、2项MySQL专用场景因未配置数据库跳过,0失败/错误;覆盖计租、资金回执、真实库存门禁、部分出库、移库换标、退货、转运及原收费入口。订单列表测试与重复注解修正后,受影响两类87项复跑通过 |
| 海外 PDA | 仓租预校验、出库生命周期、履约与排车共88项通过;未运行真实设备 |
| Admin | 新页面与接口定向测试、类型检查及构建通过;最终“头程入仓”文案修正后,展示规则3项定向复跑通过,差异格式检查通过。截图使用模拟数据与真实页面组件,不是环境验收证据 |
| 数据库静态检查 | 初始化及统一SQL合同29项通过;迁移登记检查通过;没有连接数据库或执行SQL |
| 架构与入口 | Mapper扫描424项、Lastleg依赖合同15项、Gateway路由合同5项通过;三个仓库差异格式检查通过 |
已知环境检查缺口:`agents-index-contract.ps1` 因现有外部文档 `D:/project/docs/业务快速索引.md` 缺失未通过;Admin全量密度检查存在既有 `UnifiedOrdersView` 工具栏32px问题。本次新页面密度定向检查通过,不能据此宣称全仓检查全部通过。
页面预览保存在 `D:/project/html/artifacts/overseas-fees-acceptance/`,包含费用列表、费用详情、人工费用审核、头程费用审核、仓租列表及仓租详情。
2026-10-22 客户仓租配置接入补充验证(不与上一轮测试数量累加):
| 范围 | 结果与边界 |
|---|---|
| Warehouse 客户计租接口 | 4个定向测试类共24项全部通过;覆盖客户专价、默认报价、缺失组、无效阶梯、内部鉴权及一致读取 |
| Lastleg 客户计价与出库 | 10个定向测试类共153项全部通过,无失败、错误或跳过;覆盖配置校验、动态阶梯、原批次份额、原价格快照消费、重建预占读取新价、CNY结算、混币展示和整托汇总,以及普通出库、退货出库和废弃库存门禁;生产与测试源码编译通过 |
| Admin 币种展示 | 3个定向测试文件共9项通过,类型检查通过;列表按原币分别显示,详情按收费实际币种显示,不默认所有仓租为USD |
| 静态契约 | Warehouse及仓租OpenAPI可解析;Gateway路由5项、Lastleg依赖15项通过;差异格式检查通过 |
本次接入没有增加数据库字段;首版数据库定义以“发布配套”列出的国内、海外统一空库基线为准,已有库升级须单独核对。补充验证没有执行数据库脚本、部署、真实HTTP或PDA设备验收。
旧收费切换:新换标报价不再冻结,外接运费只保留约定记录,拆柜打托只保留成本分摊。旧派送请求含客户应收将明确提示转到海外费用登记。历史换标若已发布过应收又已有冻结,结清回执只为原来源补扣款引用,绝不生成第二笔;此前进入 Finance 的旧账单不能仅靠本次发布自动回写,须在上线前核对并撤销未结应收或做既有账单核销,避免再次催收。新增旧冻结结清只在实际扣款成功后携带 command/hold/ledger 引用发布。采购独立流程及自动仓租保留。
2026-10-22 费用入口与回显修复:尾程详情录费改用公共费用目录,按应收/应付方向查询,禁止自由输入和手工添加自动仓租。目录失败可重试,未加载成功不允许保存;供应商只在应付时加载。订单费用返回并显示统一结算方式及锁款/实扣状态;统一业务类型 LASTLEG_ORDER 展示为“尾程订单”,不把转运、退货尾程单一律标成头程入仓。历史已发布账单保留原结算记录,不自动重扣。
客户名称为空时按真实客户 ID 经 IAM 内部接口补齐查询展示,保留原有名称快照,不回写历史费用;费用筛选和业务候选同步补齐名称。新录费用仍按实际 FIRST_LEG 与海外业务类型分流,前者审核入账单、后者锁款后审核扣费。
本轮验证:Lastleg 定向4类64项通过,客户查名6项通过;Admin费用详情、接口与管理列表定向测试通过,类型检查通过;Gateway路由5项、Lastleg依赖15项、差异格式检查通过。本机 Lastleg、Warehouse、Finance 已重启加载新编译版本,正式 Gateway 已核验客户名称回显、费用目录方向及仓租查询。完整业务造单、PDA出库、资金实扣的逐单结果另记本轮场景报告,以上结果不能代替全链路验收。
2026-10-23 库龄批次与操作入口调整:
- 头程同单同仓补入库复用首次计租批次,并登记收货幂等记录;其他业务继续按实际入库批次分别计龄,不自动合并历史批次。
- 页面只保留费用明细、仓租库存;库存查看库龄与余量,费用明细查询计费依据,审核和必填说明的调价放在对应尾程单据详情的费用明细。
- 自动检查:Admin 费用管理、仓租库存和单据费用组件共35项测试及类型检查通过;Lastleg费用持久化与服务50项、Gateway路由5项通过;此前入库批次相关54项和Mapper扫描425项通过,相关输入未变的结果复用。
- 本地数据库仅定向补建并核验 `ll_storage_rent_inbound` 表,没有重放空库初始化,没有修改费用或钱包记录。Lastleg与Admin服务已重启,最新检查为后端健康UP、前端HTTP200;最新前端调整由开发服务热更新加载。
- 本轮未重新执行完整PDA出库及真实资金扣款流程;最终页面布局待人工操作真实页面并截图确认。
2026-10-23 来源单展示补充:仓租库存的原业务单号明确标为「来源单号」,出库记录保留「出库业务单号」。单据费用详情及仓租审核弹框按每条原计租批次展示来源类型、来源单号和入库批次;头程货对应原尾程订单,退货货物对应原退货单,换标不覆盖原来源。查询按费用快照引用的批次编号批量读取原批次,并校验客户、仓库一致;缺失来源显示空值,不推断单号,不修改历史费用或计费金额。本次无需数据库结构变更。
来源展示自动检查:Admin费用管理、仓租库存、单据费用35项测试与类型检查通过;后端来源隔离及仓租服务37项测试通过,生产和测试源码编译通过。后端已重启加载新增查询字段;页面布局仍待真实页面人工截图确认。
2026-10-23 本机开发环境真实 HTTP 费用闭环补充(不含头程,不代表生产发布):
- 退货换标单 TH202610230002 已完成实物签收、清点、换标和 PDA 出库;出库仓租来源为退货单,原计算 USD 1.50 / CNY 10.01。调价 USD 1.00 必填原因后,旧版本审核被拒,正确版本审核及重放只生成一条 CNY 6.67 消费;释放原冻结 CNY 10.01,归还差额 CNY 3.34。另以原消费流水做 CNY 1.00 部分退款,原消费金额保持不变,退款仅增加一次余额并以 referenceNo 关联原消费。
- 仓储转运单 ZY202610230002 的自动换标单 TB202610230002 已完成真实箱数清点、入库计龄、换标和 PDA 出库。仓租详情确认来源是 WAREHOUSE_TRANSFER / ZY202610230002,批次 ZY202610230002,数量 1、库龄 1 天;免费 USD 仓租保留 NULL 汇率。原零元改 USD 1.00 并采用实际月汇率 6.67,先冻结 CNY 6.67,审核前不实扣;旧版本审核拒绝,正确审核和重放后仅消费一次 CNY 6.67。原批次余量 0,在库仓租页总数为 0。
- 自动仓租、本机实际资金流水及数据库金额/Fx边界的 30 项在线断言通过:原零元调价原因/版本门禁、差额冻结/释放、余额实扣、驳回与释放、重复请求、超余额冻结失败、原消费部分退款、来源/库存过滤均有报告。人工费用额外 6 项通过:退货单人工应收 1.23 先冻结后审核只扣一次,转运单 1.11 驳回后解冻,超余额费用不得审核且没有新增冻结或扣款。
- 本机开发库已按用户单独授权执行 overseas-storage-rent-zero-fee-repair.sql;仅修正指定零元自动仓租金额/汇率约束,保留汇率 DEFAULT 1 但 MyBatis 显式写 NULL,未改业务行。Lastleg 重启加载最终代码。自动化持久化测试 3 项及仓租状态机 42 项通过。
- Admin 自动化覆盖账户失败重试、500 字退款说明边界及换标资金状态中文映射;聚焦测试和类型检查通过。实际浏览器曾核对退货仓租详情及换标单费用区的审核/调价入口、出入库来源明细;自动仓租审核前“费用状态 —”符合现有契约(仅完成/失败显示状态),旁列显示待审核。
- 美国卡车 `TR202610230001` 的统一目录应收已在线验证:1.23 元先冻结、审核同键重放只实扣一次,按卡车来源类型/单号查询正确回显;随后原消费关联退款 1.23 元且仅一条退款流水,再按正式取消接口将验收订单收口。卡车链路 7 项在线断言通过。
- 散板在线验收被本机数据库结构漂移阻断,没有创建散板单。Lastleg 错误为 `ll_local_looseboard_order.transport_mode` 无默认值;开发库结构快照还显示 `pallet_required`、`transport_type` 等旧列与当前实体/基线不一致。当前发布基线和 `LocalLooseboardSchemaContractTest` 均明确排除 `transport_mode`。按授权边界没有为此执行 SQL 或修改业务代码,需先核对并获准将本机 Lastleg 表收敛到当前正式基线,再重试散板链路。
- UI自动化继续检查时浏览器控制通道超时;此前已读到真实换标单详情 DOM,但本轮缺少最终页面截图,视觉验收状态保留为未完整确认。
- 在线报告位于 D:/project/.codex-temp/overseas-release-live-report.json、overseas-money-live-report.json、overseas-refund-live-report.json、overseas-manual-money-live-report.json、overseas-zero-adjust-live-report.json;账户凭据和会话不在报告中。
2026-10-23 接续验收:
- 修正 `OverseasManualFeePersistenceTest` 的隔离 H2 费用表夹具,补齐 `exchange_rate` 并将无列名插入改为显式列名;`LocalLooseboardSchemaContractTest`、`LocalLooseboardPersistenceTest`、`OverseasManualFeePersistenceTest` 共14项定向测试通过。
- 采购与费用跨区离线合同22项通过;Lastleg采购结构、OpenAPI、库存边界、请求边界、费用持久化共38项通过;Finance采购结算、海外钱包收口、派送费用覆盖共26项通过。以上均为本机自动化检查,不等于真实HTTP或页面验收。
- 浏览器重试仍在读取当前登录页时超时,页面视觉验收继续保留未完成状态。
- 散板本机结构只读对比已确认:`ll_local_looseboard_order` 仍含旧的 `transport_mode`、`container_type`、`contact_name`、`contact_info`、`requested_date` 及旧日期索引;当前正式基线要求移除这些字段并使用 `product_name`、`estimated_pallet_count`、双联系人、`pickup_at`/`delivery_at` 及新日期索引。未执行数据库变更,待单独授权后再按可审阅增量收敛并重试散板费用链路。
当前不能标为海外费用全量上线验收通过:采购专用付款/部分退款及最终交寄门禁、散板单据费用创建和回显、派送费用入口迁移及旧入口不重复计费、应付账期/直付不改变客户钱包、真实角色权限与跨客户伪造归属拒绝,仍需分别针对实际业务单和部署授权做验收;卡车仅完成通用费用闭环,不代表车队履约生命周期已验。自动化测试覆盖了其中部分服务逻辑,但没有替代这些真实 HTTP 场景。本机 Gateway/数据库验证不代表生产部署;双设备竞争、MySQL 锁/回滚、未知网络结果与真实事件错序回执也仍未通过真实环境验收。
2026-10-24 接续:
- 已按单独授权执行本机散板结构收敛:正式表 `ll_local_looseboard_order` 备份为 `backup_ll_local_looseboard_order_20261023`,移除旧字段并补齐当前实体所需可空/默认值及日期索引;正式表与备份表均保留4条原记录。
- Lastleg 已用完整本地环境变量恢复,`http://127.0.0.1:9303/actuator/health` 返回 UP;散板结构、持久化和人工费用14项定向测试全部通过。
- 真实消费者造单夹具尚未完成:其测试上下文仍被 Spring Boot/Nacos config import 解析阻断,未据此伪造散板 HTTP 通过结论,也未重复写入已存在的换标、退货或卡车验收数据。
## 2026-10-24 费用风险回归补充
- Lastleg 定向回归 96 项实际执行通过:派送费用入口、人工费用、采购专用资金、费用取消/补偿、Outbox 重试、散板结构和费用归属均无失败。
- Finance 定向回归 49 项实际执行通过:冻结/实扣/释放事务回滚、重复及冲突回执、Outbox 租约、原消费退款、客户账户隔离均无失败。
- 新增 `PlatformOverseasFeeSourceControllerSecurityTest`,验证非 `PLATFORM + tenant=0` 主体在读取费用前直接返回 403;1 项通过。
- `CargoDiscardMysqlAcceptanceTest` 本轮仍因本机未开启 MySQL 验收开关而跳过,不能视为废弃费用真实数据库验收通过。
当前仍不能标记为“海外费用全部上线通过”的门槛:采购真实 HTTP 付款/部分退款/交寄门禁、DELIVERY_ORDER 真实费用迁移且旧入口不重复计费、卡车/散板真实单据费用目录闭环、账期/直付应付不触碰客户钱包、真实角色与跨客户 HTTP 隔离、双设备并发/超量出库、MySQL 锁回滚及未知网络回执。这些已有隔离测试或服务层证据,但尚缺真实服务/数据库证据。
## 2026-10-26 缺口修复与散板真实接口复验
- 本机 Lastleg 按完整 Nacos/JWT/Redis 环境重启,`/actuator/health` 返回 `UP`,并完成 Nacos 注册;此前网关 503 的直接原因是 9303 未启动/未注册,不是散板业务异常。
- 使用新幂等键 `OVERSEAS-TEST-20261026-loose` 调用真实 Lastleg 接口创建散板订单,返回 `200`,订单号 `LB202610260002`,状态 `PENDING_ACCEPTANCE`。
- 使用同一幂等键重复创建,返回同一订单 ID 和订单号;真实详情查询返回同一订单及状态,验证散板创建幂等和客户隔离查询。
- 费用业务候选接口真实返回该散板订单,业务类型 `LOCAL_LOOSEBOARD`、业务单号 `LB202610260002`、客户 ID 一致。
- Finance 服务当前未在本机注册,网关账户接口仍返回 `503`;因此散板费用冻结、审核、实扣和退款不能冒充真实 HTTP 已通过。Finance 定向自动化 49 项仍全部通过。
## 2026-10-26 缺口修复接续
- Finance 已使用完整本机 Nacos/JWT/Redis 环境恢复,`9203` 启动并注册;散板费用真实闭环已补验:`LB202610260002` 的登记冻结、审核实扣、详情回显、重复审核只扣一次均通过。
- 散板原消费已做真实全额退款验收:重复退款复用同一流水、余额只恢复一次、超额退款及同键改金额均拒绝。
- 真实权限/归属风险已验收:客户令牌访问平台费用和退款均拒绝;伪造客户 ID 或业务单号创建费用均拒绝。
- 采购真实 HTTP 验收已创建测试采购单并验证下单幂等,但凭证上传因当前本机管理员会话缺少正式 `platform:overseas-business:purchase:buy` 权限被拒绝,不能将采购付款、部分退款、交寄门禁标记为通过。现有权限定义和服务层定向测试仍有效,需补齐本机角色授权后继续。
- 回归期间发现 `LastlegOrderServiceTest` 使用旧版查询构造参数,已补齐测试调用;Lastleg 采购资金相关四个定向测试(`ProcurementFinancePersistenceTest`、`ProcurementPurchasePersistenceTest`、`ProcurementControlPersistenceTest`、`ProcurementChargePersistenceTest`)及生产/测试编译通过。
- 当前仍未完成:采购真实付款/部分退款/交寄门禁、派送 `DELIVERY_ORDER` 真实迁移与旧入口去重、应付账期/直付不触碰客户钱包、真实角色矩阵验收、双设备并发/超量出库、MySQL 锁回滚和未知网络回执;页面视觉验收也仍需人工截图确认。
@@ -0,0 +1,12 @@
# 采购双方向履约
2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。
- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;平台审核仅展示客户下单方向,不允许审核改向;采购仓按该方向筛选。审核底部直接取消、拒绝或通过,拒绝必须填写具体原因。
- 起运收货仓按方向读取 Warehouse 权威公共启用仓目录,国内方向只接受 DOMESTIC,进口方向保持海外 RECEIVING 能力约束。实际收货、计划仓更改、实际仓更正及面单寄件地址均校验同一方向。
- 出口流程:审核 → 国内购买 → 国内分次实收并确认 → 装箱 → 国际面单、预估运费冻结、贴单 → 国内国际交寄 → 海外分次实收并确认 → 海外核定实际费用 → 寄海外客户。正常终态仍为 SHIPPED_TO_CUSTOMER,取消、拒绝、异常关闭规则保留。
- 此次沿用单商品、一次购买、两段各整批交寄及 Admin 台账,不新增 WMS 库存、PDA 入账、分批购买或分批交寄。目的地接收地址沿用国际面单地址快照,最终客户地址由下单提供,最终面单变更仍需客户凭证。
- 为保护历史数据关联,技术编码 OVERSEAS/DOMESTIC、overseas_received_at/domestic_received_at 及既有状态编码分别表示起运段/目的地段;业务展示按方向转换国内、海外。权限编码、费用用途 DOMESTIC_FREIGHT 和资金引用保持兼容;费用名称按实际目的地区域保存。
- 版本与幂等覆盖方向;未传新字段的旧请求序列化忽略空方向,保留旧哈希。上线先执行 V34,再发布 Lastleg、Admin、Client;不能仅发布前端。
自动验证覆盖方向持久化与幂等、国内仓主档校验、两段出口地址与交寄终态,以及页面切换方向后的仓库候选和旧请求重试。真实服务与人工截图验收另行记录。
@@ -64,6 +64,8 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target.startsWith('com.baiyue.lastleg.fleet.')); && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0); assert.ok(dispatchEdges.length > 0);
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target) assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService'
&& ['com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort', 'com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels'].includes(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter' || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target) && /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper' || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
@@ -82,6 +84,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService', 'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
'TransportTaskService->reporting.TripOperationsEvidenceGateway', 'TransportTaskService->reporting.TripOperationsEvidenceGateway',
'TransportTaskService->photo.FleetResourcePhotoModels',
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。 // 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity', 'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)), ].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
@@ -19,6 +19,11 @@ public interface DomesticWarehouseDirectoryClient {
ApiResponse<ReceivingWarehouse> receivingWarehouse(@PathVariable("warehouseId") long warehouseId, ApiResponse<ReceivingWarehouse> receivingWarehouse(@PathVariable("warehouseId") long warehouseId,
@RequestHeader("X-Internal-Service-Token") String internalToken); @RequestHeader("X-Internal-Service-Token") String internalToken);
/** 启用的国内公共仓库,供国内采购收货选择。 */
@GetMapping("/internal/warehouses/domestic")
ApiResponse<List<DomesticWarehouse>> domesticWarehouses(@RequestHeader("X-Internal-Service-Token") String internalToken);
record DomesticWarehouse(Long id, String warehouseCode, String warehouseName) { }
record ReceivingWarehouse(Long warehouseId, String warehouseCode, String warehouseName, String countryCode, String status) { } record ReceivingWarehouse(Long warehouseId, String warehouseCode, String warehouseName, String countryCode, String status) { }
@GetMapping("/internal/warehouses/0/{warehouseId}/active") @GetMapping("/internal/warehouses/0/{warehouseId}/active")
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.util.List;
/** 费用目录只读契约,不共享头程领域实体。 */
@FeignClient(name = "firstleg-service", contextId = "firstlegFeeCatalogClient", url = "${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}")
public interface FirstlegFeeCatalogClient {
/** 获取海外仓可选项目。 */
@GetMapping("/internal/firstleg/v1/fee-items")
ApiResponse<List<Item>> list(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("direction") String direction);
/** 验证实际录费使用的项目。 */
@GetMapping("/internal/firstleg/v1/fee-items/{code}")
ApiResponse<Item> detail(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("code") String code, @RequestParam("direction") String direction);
record Item(String code, String name, BigDecimal defaultPrice) { }
}
@@ -91,6 +91,8 @@ import java.util.stream.Collectors;
@Service @Service
public class DeliveryTransferService { public class DeliveryTransferService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@Resource private DispatchAggregateRepository dispatches; @Resource private DispatchAggregateRepository dispatches;
@Resource private DispatchItemMapper dispatchItems; @Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundOrderMapper outboundOrders;
@@ -526,6 +528,7 @@ public class DeliveryTransferService {
return pickResolved(warehouseId, operatorId, taskNo, request, false); return pickResolved(warehouseId, operatorId, taskNo, request, false);
} }
/** PDA 真实出库在扣库存前校验同一扫码的仓租冻结,原幂等结果优先恢复。 */
private DeliveryResponses.PdaTask pickResolved(String warehouseId, String operatorId, String taskNo, private DeliveryResponses.PdaTask pickResolved(String warehouseId, String operatorId, String taskNo,
DeliveryRequests.PdaPick request, boolean palletScan) { DeliveryRequests.PdaPick request, boolean palletScan) {
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"), OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
@@ -560,6 +563,9 @@ public class DeliveryTransferService {
if (item == null || !customerId.equals(item.getCustomerId()) if (item == null || !customerId.equals(item.getCustomerId())
|| !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity"); if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity");
storageRent.consume(warehouseId, new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
"OUTBOUND", taskNo, request.outboundPalletItemId(), request.inventoryPositionId(), request.packages(), null, null,
request.idempotencyKey(), request.cartonMarks(), request.skuCode(), request.productQuantity()));
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) { || ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) {
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) && StringUtils.hasText(item.getPalletNo()) && !palletScan) if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) && StringUtils.hasText(item.getPalletNo()) && !palletScan)
@@ -729,10 +735,23 @@ public class DeliveryTransferService {
return taskResult(outbound, task); return taskResult(outbound, task);
} }
/** 扫描整托前核平全部指定来源,任一不足时不执行部分拣货。 */ /** 整托扫描使用与资金预校验完全一致的实际子来源,任一失败则整托回滚。 */
@Transactional @Transactional
public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo, public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo,
String palletNo, DeliveryRequests.PdaPalletPick request) { String palletNo, DeliveryRequests.PdaPalletPick request) {
var children = storageRentPalletRequests(warehouseId, operatorId, taskNo, palletNo, request);
DeliveryResponses.PdaTask result = null;
for (var child : children) result = pickResolved(warehouseId, operatorId, taskNo, child, true);
if (result != null) return result;
var task = pdaTasks.lockByNoAndWarehouse(taskNo, warehouseId);
if (task == null) throw notFound("当前仓没有该出库工单");
return taskResult(requireOutbound(task), task);
}
/** 资金预校验和实际整托扫码共享同一来源展开及子扫描幂等键算法。 */
@Transactional
public java.util.List<DeliveryRequests.PdaPick> storageRentPalletRequests(String warehouseId, String operatorId, String taskNo,
String palletNo, DeliveryRequests.PdaPalletPick request) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
String code = required(taskNo, "PDA task number is required"); String code = required(taskNo, "PDA task number is required");
String palletCode = required(palletNo, "Pallet number is required"); String palletCode = required(palletNo, "Pallet number is required");
@@ -752,7 +771,7 @@ public class DeliveryTransferService {
.stream().filter(item -> palletCode.equals(item.getPalletNo())).toList(); .stream().filter(item -> palletCode.equals(item.getPalletNo())).toList();
if (palletItems.isEmpty()) throw notFound("当前出库任务没有该托盘"); if (palletItems.isEmpty()) throw notFound("当前出库任务没有该托盘");
if (palletItems.stream().allMatch(item -> value(item.getPickedPackageCount()) >= value(item.getPackageCount()))) { if (palletItems.stream().allMatch(item -> value(item.getPickedPackageCount()) >= value(item.getPackageCount()))) {
return taskResult(outbound, task); return java.util.List.of();
} }
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
@@ -763,14 +782,9 @@ public class DeliveryTransferService {
!task.getCustomerId().equals(carton.getCustomerId()) || !expectedMarks.contains(carton.getCartonMark()) !task.getCustomerId().equals(carton.getCustomerId()) || !expectedMarks.contains(carton.getCartonMark())
|| !"OUTBOUND_PENDING".equals(carton.getStatus()))) || !"OUTBOUND_PENDING".equals(carton.getStatus())))
throw conflict("托盘内容或出库预留已变化,请刷新核对"); throw conflict("托盘内容或出库预留已变化,请刷新核对");
DeliveryResponses.PdaTask result = taskResult(outbound, task); return palletItems.stream().filter(item -> value(item.getPickedPackageCount()) < value(item.getPackageCount()))
for (OutboundPalletItem item : palletItems.stream().sorted(Comparator.comparing(OutboundPalletItem::getId)).toList()) { .sorted(Comparator.comparing(OutboundPalletItem::getId)).map(item -> new DeliveryRequests.PdaPick(item.getId(), null, 1L,
if (value(item.getPickedPackageCount()) >= value(item.getPackageCount())) continue; childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks()))) .toList();
result = pickResolved(boundWarehouseId, operatorId, code,
new DeliveryRequests.PdaPick(item.getId(), null, 1L,
childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())), true);
}
return result;
} }
List<PalletPickPlan> plans = new ArrayList<>(); List<PalletPickPlan> plans = new ArrayList<>();
@@ -797,13 +811,8 @@ public class DeliveryTransferService {
if (remaining > 0) throw conflict("托盘可用库存不足,整托拣货未执行:" + palletCode); if (remaining > 0) throw conflict("托盘可用库存不足,整托拣货未执行:" + palletCode);
} }
DeliveryResponses.PdaTask result = taskResult(outbound, task); return plans.stream().map(plan -> new DeliveryRequests.PdaPick(plan.itemId(), plan.positionId(), plan.packages(),
for (PalletPickPlan plan : plans) { palletPickOperationId(request.idempotencyKey(), code, palletCode, plan), plan.cartonMarks())).toList();
String operationId = palletPickOperationId(request.idempotencyKey(), code, palletCode, plan);
result = pdaPick(boundWarehouseId, operatorId, code, new DeliveryRequests.PdaPick(
plan.itemId(), plan.positionId(), plan.packages(), operationId, plan.cartonMarks()));
}
return result;
} }
@Transactional @Transactional
@@ -1011,7 +1020,7 @@ public class DeliveryTransferService {
delivery.setUpdatedAt(now); cancellations.save(delivery); delivery.setUpdatedAt(now); cancellations.save(delivery);
} }
OutboundPdaTask task = cancellations.task(outbound); OutboundPdaTask task = cancellations.task(outbound);
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); } if (task != null) { storageRent.cancelExecution("OUTBOUND",task.getTaskNo()); task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); }
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); cancellations.save(outbound); outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); cancellations.save(outbound);
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); cancellations.save(item); } for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); cancellations.save(item); }
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
@@ -116,17 +116,13 @@ public class DispatchDeliveryFees {
if (fee==null || blank(fee.feeType()) || !SERVICES.contains(fee.feeType()) || blank(fee.direction()) || !Set.of("RECEIVABLE","PAYABLE").contains(fee.direction()) if (fee==null || blank(fee.feeType()) || !SERVICES.contains(fee.feeType()) || blank(fee.direction()) || !Set.of("RECEIVABLE","PAYABLE").contains(fee.direction())
|| blank(fee.currency()) || !Set.of("CNY","USD").contains(fee.currency()) || blank(fee.feeName()) || fee.amount()==null || fee.amount().signum()<=0) || blank(fee.currency()) || !Set.of("CNY","USD").contains(fee.currency()) || blank(fee.feeName()) || fee.amount()==null || fee.amount().signum()<=0)
throw conflict("派送费用类型、方向、币种或金额不合法"); throw conflict("派送费用类型、方向、币种或金额不合法");
if ("RECEIVABLE".equals(fee.direction())) throw conflict("请到海外费用登记并审核应收费用");
if (!seen.add(fee.direction()+":"+fee.feeType())) throw conflict("同组同方向同费用不得重复登记"); if (!seen.add(fee.direction()+":"+fee.feeType())) throw conflict("同组同方向同费用不得重复登记");
if ("RECEIVABLE".equals(fee.direction()) && group.cargo().stream().allMatch(c->covered(c,fee.feeType(),evidence,freight))) if ("RECEIVABLE".equals(fee.direction()) && group.cargo().stream().allMatch(c->covered(c,fee.feeType(),evidence,freight)))
throw conflict("本组该服务已全部覆盖,不得重复登记应收"); throw conflict("本组该服务已全部覆盖,不得重复登记应收");
} }
var required=new HashSet<String>(); required.add("DELIVERY_FEE"); if ("BUY_APPOINTMENT".equals(input.appointmentAccount()) && blank(input.sellerName()))
if (Set.of("FLOOR","MIXED").contains(input.loadingType())) required.add("LOADING_FEE"); throw conflict("买约须填写卖家名称");
if ("BUY_APPOINTMENT".equals(input.appointmentAccount())) {
if (blank(input.sellerName())) throw conflict("买约须填写卖家名称");required.add("APPOINTMENT_FEE");
}
for (var code:required) if (group.cargo().stream().anyMatch(c->!covered(c,code,evidence,freight)) && !seen.contains("RECEIVABLE:"+code))
throw conflict("本组尚有未覆盖的服务费用,请核对" );
} }
/** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */ /** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */
@@ -134,7 +130,7 @@ public class DispatchDeliveryFees {
fees.requireNoActiveDeliveryReceivables(customerId,freight.priorInboundCodes()); fees.requireNoActiveDeliveryReceivables(customerId,freight.priorInboundCodes());
} }
/** 费用与子单同事务保存,只向真实客户目的地发布一次正数来源。 */ /** 外接报价保留记录;只发布供应商应付,客户应收走人工审核。 */
public void publish(Context context,Group group,ArrangeGroup input,DeliveryOrder delivery, public void publish(Context context,Group group,ArrangeGroup input,DeliveryOrder delivery,
DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight,String operatorId,String arrangementKey) { DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight,String operatorId,String arrangementKey) {
var now=LocalDateTime.now(); var now=LocalDateTime.now();
@@ -144,12 +140,10 @@ public class DispatchDeliveryFees {
operation.setOperatorId(operatorId);operation.setOperationNode("CONFIRM_EXTERNAL_FREIGHT"); operation.setOperatorId(operatorId);operation.setOperationNode("CONFIRM_EXTERNAL_FREIGHT");
operation.setResult("外接预订 "+quote.bookingNo()+";实收确认版本 "+quote.recordVersion()+";约定运费 "+quote.currency()+" "+quote.amount().toPlainString()); operation.setResult("外接预订 "+quote.bookingNo()+";实收确认版本 "+quote.recordVersion()+";约定运费 "+quote.currency()+" "+quote.amount().toPlainString());
operation.setCreatedAt(now);repository.operation(operation); operation.setCreatedAt(now);repository.operation(operation);
if (quote.amount().signum()==0) continue; // 约定运费只保留商业记录,应收由海外费用人工登记审核。
fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),quote.bookingNo(),"FREIGHT")),
save(group,delivery,null,"DELIVERY_FEE","外接约定运费", "RECEIVABLE",quote.currency(),quote.amount(),
quote.quantity(),quote.quantityUnit(),quote.unitPrice(),null,quote.inboundCode()));
} }
for (var fee:input.fees()==null?List.<FeeItem>of():input.fees()) { for (var fee:input.fees()==null?List.<FeeItem>of():input.fees()) {
if (!"PAYABLE".equals(fee.direction())) throw conflict("请到海外费用登记并审核应收费用");
var charged=group.cargo().stream().filter(c->!"RECEIVABLE".equals(fee.direction())||!covered(c,fee.feeType(),evidence,freight)).toList(); var charged=group.cargo().stream().filter(c->!"RECEIVABLE".equals(fee.direction())||!covered(c,fee.feeType(),evidence,freight)).toList();
var orders=charged.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList(); var orders=charged.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList();
var inbound=charged.stream().map(CargoLine::inboundCode).filter(Objects::nonNull).distinct().toList(); var inbound=charged.stream().map(CargoLine::inboundCode).filter(Objects::nonNull).distinct().toList();
@@ -164,7 +158,7 @@ public class DispatchDeliveryFees {
String direction,String currency,BigDecimal amount,BigDecimal quantity,String unit,BigDecimal unitPrice,FeeItem input,String inboundCode) { String direction,String currency,BigDecimal amount,BigDecimal quantity,String unit,BigDecimal unitPrice,FeeItem input,String inboundCode) {
return new OverseasFeeSourceDtos.SaveRequest(group.customerId(),group.customerName(),"OVERSEAS_DELIVERY_FEE","DELIVERY_ORDER", return new OverseasFeeSourceDtos.SaveRequest(group.customerId(),group.customerName(),"OVERSEAS_DELIVERY_FEE","DELIVERY_ORDER",
delivery.getId(),delivery.getDeliveryNo(),orderId,null,inboundCode,null,null,null,Long.valueOf(delivery.getOverseasWarehouseId()), delivery.getId(),delivery.getDeliveryNo(),orderId,null,inboundCode,null,null,null,Long.valueOf(delivery.getOverseasWarehouseId()),
code,name,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(), code,name, null,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(),
input==null?null:input.settlementMode(),input==null||input.paidAt()==null?null:LocalDateTime.ofInstant(input.paidAt(),ZoneId.systemDefault()), input==null?null:input.settlementMode(),input==null||input.paidAt()==null?null:LocalDateTime.ofInstant(input.paidAt(),ZoneId.systemDefault()),
input==null?null:input.paymentMethod(),input==null?null:input.paymentReferenceNo(),input==null?null:input.paymentVoucherFileIds(), input==null?null:input.paymentMethod(),input==null?null:input.paymentReferenceNo(),input==null?null:input.paymentVoucherFileIds(),
LocalDate.now(),currency,amount,quantity,unit,unitPrice,input==null?null:input.periodStart(),input==null?null:input.periodEnd()); LocalDate.now(),currency,amount,quantity,unit,unitPrice,input==null?null:input.periodStart(),input==null?null:input.periodEnd());
@@ -16,8 +16,12 @@ public final class PdaCargoDiscardDtos {
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
public record Confirm(@NotEmpty List<@Valid ActualLine> lines, public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds, @NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) { List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); } @NotBlank @Size(max=255) String idempotencyKey) {
/** 兼容已有调用,正式 PDA 必须提交扫码幂等键。 */
public Confirm(List<ActualLine> lines,List<Long> photos,List<String> marks,Boolean all){this(lines,photos,marks,all,null);}
/** 兼容已有内部照片确认调用。 */
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false,null); }
} }
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */ /** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
@@ -23,6 +23,7 @@ public class PdaCargoDiscardService {
@Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private CargoDiscardService discardService; @Resource private CargoDiscardService discardService;
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService;
@Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
/** 只读取绑定仓已审核申请的实际托盘任务。 */ /** 只读取绑定仓已审核申请的实际托盘任务。 */
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) { public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
@@ -95,6 +96,9 @@ public class PdaCargoDiscardService {
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。 // 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
if (terminal) throw conflict("废弃终态不能执行未完成托盘"); if (terminal) throw conflict("废弃终态不能执行未完成托盘");
storageRent.consume(String.valueOf(identity.overseasWarehouseId()),new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
"DISCARD",discardNo,null,null,null,null,palletNo,request.idempotencyKey(),null,null,null,
request.lines(),request.photoFileIds(),request.scannedCartonMarks(),request.confirmAllCartons()));
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
for (CargoDiscardItem item : palletItems) { for (CargoDiscardItem item : palletItems) {
if ("COMPLETED".equals(item.getPdaStatus())) { if ("COMPLETED".equals(item.getPdaStatus())) {
@@ -50,6 +50,7 @@ import java.util.Set;
@Service @Service
public class CargoDiscardService { public class CargoDiscardService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@Resource private CargoDiscardStatisticsService statisticsService; @Resource private CargoDiscardStatisticsService statisticsService;
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
@@ -389,6 +390,7 @@ public class CargoDiscardService {
release(customerId, application, links, items.listApplication(customerId, application.getId())); release(customerId, application, links, items.listApplication(customerId, application.getId()));
LocalDateTime at = now(); LocalDateTime at = now();
String from = application.getStatus(); String from = application.getStatus();
storageRent.cancelExecution("DISCARD",discardNo);
application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy()); application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy());
application.setReviewRemark(platform ? "平台取消" : application.getReviewRemark()); application.setReviewRemark(platform ? "平台取消" : application.getReviewRemark());
application.setUpdatedAt(at); applications.updateById(application); application.setUpdatedAt(at); applications.updateById(application);
@@ -122,7 +122,7 @@ public interface DispatchAggregateMapper {
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount}, delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt} shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
WHERE id=#{r.id} AND record_version=#{version} WHERE id=#{r.id} AND record_version=#{version}
""") """)
@@ -11,6 +11,8 @@ import java.util.List;
public interface DispatchCandidateMapper { public interface DispatchCandidateMapper {
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */ /** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))"; String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
/** 完成入库或已确认短少后,不再把未实收的预留托盘当作在途货;真实库存仍可排车。 */
String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')";
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */ /** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
String DEPARTED_PLAN = String DEPARTED_PLAN =
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + "EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
@@ -178,7 +180,7 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
@@ -283,7 +285,7 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
@@ -33,7 +33,7 @@ public interface DispatchSourceMapper {
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1 HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1 AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
@@ -41,6 +41,23 @@ public interface DispatchSourceMapper {
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id); List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
/** 只有原客户、原计划、原仓已结束且逐项明确为零的来源可移除;缺记录不代表短少。 */
@Select("""
SELECT COUNT(*) FROM ll_inbound_plan_pallet p
JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan}
AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse}
AND ip.status IN ('COMPLETED','PARTIAL_INBOUND')
AND COALESCE(p.actual_package_count,0)=0
AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id
AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0))
AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id
WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
""")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse);
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */ /** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p " @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id") + "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
@@ -115,7 +132,7 @@ public interface DispatchSourceMapper {
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */ /** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id " @Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')") + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')")
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id); int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
@@ -68,6 +68,8 @@ public class DispatchSourceRepository {
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */ /** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); } public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
/** 空预排来源必须具备已完成的零实收事实,不能把来源缺失当作零。 */
public boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; }
/** 取得已有真实退回候选。 */ /** 取得已有真实退回候选。 */
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
/** 同托真实来源订单组成。 */ /** 同托真实来源订单组成。 */
@@ -32,7 +32,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service @Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class); private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
@jakarta.annotation.Resource private DispatchNumberGenerator numbers; @jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@@ -310,7 +312,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0))); row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds(); var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos); if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources)); Resources resources=masterPhotos.populate(id,actor.operatorId(),resources(shell,photos,null)); row.setResourcesJson(write(resources));
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate()); row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt())); row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0); row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
@@ -376,6 +378,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
// 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。 // 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。
var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
releaseSources(row,actor,items,now()); releaseSources(row,actor,items,now());
storageRent.cancelExecution("DISPATCH",number);
fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消"); fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消");
return saveCommand(row,actor,identity,request.reason()); return saveCommand(row,actor,identity,request.reason());
} }
@@ -443,7 +446,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos); if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class))); var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class)));
DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row)); DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row));
var savedResources=resources(actual,photos,request.maintenance()); var savedResources=masterPhotos.populate(row.getId(),actor.operatorId(),resources(actual,photos,request.maintenance()));
// 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。 // 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。
var savedDriver=savedResources.driver(); var savedDriver=savedResources.driver();
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) { if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
@@ -733,6 +736,25 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */ /** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) { void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
long version=row.getRecordVersion(); long version=row.getRecordVersion();
// 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。
var all=repository.cargo(row.getId());
boolean removedItems=!Objects.equals(row.getItemCount(),all.size());
int previousPackages=row.getPackageCount();
row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum()));
row.setItemCount(all.size());
row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")));
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1));
if(removedItems || previousPackages!=row.getPackageCount()) {
var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
}
if(all.isEmpty()) {
invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核");
row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false;
}
if(preserveReview)row.setReviewBasisHash(basis(row)); if(preserveReview)row.setReviewBasisHash(basis(row));
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化"); if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
@@ -775,21 +797,22 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
} }
/** 三类均须含至少一张当前有效图片;保存草稿不调用此送审/通过门禁。 */ /** 车辆和司机须有有效图片,驾驶证接受与档案一致的PDF或图片。 */
private void verifyReviewPhotos(DispatchAggregate row,Resources resources) { private void verifyReviewPhotos(DispatchAggregate row,Resources resources) {
var driver=resources.driver();var proof=driver.evidence(); var driver=resources.driver();var proof=driver.evidence();
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片"); requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片");
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片"); requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片");
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片"); requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片");
} }
/** 文件存在、归属及有效性由原文件提供者复验,PDF不能充当照片。 */ /** 文件存在、归属及有效性由原提供者复验,PDF仅可作为驾驶证。 */
private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) { private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) {
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label); if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label);
boolean image=false; boolean image=false;
for(var id:ids) { for(var id:ids) {
var master=masterEvidenceDriverId(row,kind,id); var master=masterEvidenceDriverId(row,kind,id);
var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id); var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id);
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())))image=true; if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())
|| kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER && "application/pdf".equals(file.contentType())))image=true;
} }
if(!image)throw bad("请上传至少一张有效"+label); if(!image)throw bad("请上传至少一张有效"+label);
} }
@@ -67,7 +67,13 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接"); if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
boolean preserveReview=workflow.currentReceiptBasis(row); boolean preserveReview=workflow.currentReceiptBasis(row);
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>(); var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
for(var item:pending) { var empty=new ArrayList<DispatchItem>();
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) {
empty.add(item);
continue;
}
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources) var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库")); .orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
received.put(item,actual); received.put(item,actual);
@@ -79,14 +85,25 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg()); entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg());
entry.getKey().setVolumeCbm(entry.getValue().volumeCbm()); entry.getKey().setVolumeCbm(entry.getValue().volumeCbm());
} }
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { for(var item:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId()); var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0)) if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库"); || !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
} }
var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList(); // 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。
if(!empty.isEmpty()) {
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null);
var original=physicalClaims.lockOwner(owner);
for(var item:empty) {
if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId())))
throw conflict("空托盘已有真实拣货,不能按短少移除");
if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化");
}
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
}
var pallets=received.keySet().stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at); var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
for(var entry:received.entrySet()) { for(var entry:received.entrySet()) {
var item=entry.getKey();var actual=entry.getValue(); var item=entry.getKey();var actual=entry.getValue();
@@ -95,16 +112,8 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存"); if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
} }
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList()); appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList());
int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()); for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化");
if(total!=row.getPackageCount()) {
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(number,total,stops);
}
row.setPackageCount(total);
row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
workflow.saveReceivedCargo(row,operator,at,preserveReview); workflow.saveReceivedCargo(row,operator,at,preserveReview);
} }
} }
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind;
import com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER;
/** 档案照片只在缺少本车照片时带入,复制为本车证据以冻结历史与文件权限。 */
@Service
public class DispatchMasterPhotoService {
private final FleetResourcePhotoPort photos;
private final DispatchEvidenceGateway evidence;
/** 只读主档并通过本车文件网关创建证据。 */
public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) {
this.photos=photos; this.evidence=evidence;
}
/** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */
public Resources populate(long dispatchId,String operator,Resources value) {
var v=value.vehicle(); var d=value.driver();
if(v!=null && v.mode()==MASTER && v.vehicleId()!=null) {
var ids=populate(dispatchId,operator,Kind.VEHICLE,v.vehicleId(),v.evidenceFileIds());
v=new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),ids);
}
if(d!=null && d.mode()==MASTER && d.driverId()!=null && d.evidence()!=null) {
var e=d.evidence();
var ids=populate(dispatchId,operator,Kind.DRIVER,d.driverId(),e.photoFileIds());
d=new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),new DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt(),ids,e.masterLicenseFileId()));
}
return new Resources(v,d,value.maintenance());
}
/** 只复制已经由档案服务确认的图片,上传失败使业务事务整体失败。 */
public List<Long> populate(long dispatchId,String operator,Kind kind,long resourceId,List<Long> existing) {
if(existing!=null && !existing.isEmpty())return List.copyOf(existing);
var current=photos.current(kind,resourceId);
if(current.photo()==null)return List.of();
var source=photos.download(kind,resourceId,Long.parseLong(current.photo().fileId()));
var file=new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content());
var copied=evidence.upload(dispatchId,kind==Kind.VEHICLE?DispatchEvidenceGateway.Kind.VEHICLE:DispatchEvidenceGateway.Kind.DRIVER_PHOTO,operator,file);
return List.of(copied.fileId());
}
/** 只供服务间复制已核验图片,不接受任意磁盘路径。 */
private record SnapshotFile(String filename,String contentType,byte[] bytes) implements MultipartFile {
/** 上传部件固定使用文件字段。 */
@Override public String getName() { return "file"; }
/** 保留已核验主档文件名。 */
@Override public String getOriginalFilename() { return filename; }
/** 沿用已核验图片类型。 */
@Override public String getContentType() { return contentType; }
/** 拒绝空图片。 */
@Override public boolean isEmpty() { return bytes.length==0; }
/** 使用实际读取的字节长度。 */
@Override public long getSize() { return bytes.length; }
/** 返回本次复制的图片内容。 */
@Override public byte[] getBytes() { return bytes; }
/** 提供上传流,不读取磁盘文件。 */
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
/** 实现文件上传协议要求的输出方法。 */
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(),bytes); }
}
}
@@ -23,6 +23,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ /** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
@Service @Service
public class DispatchPickingService { public class DispatchPickingService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@jakarta.annotation.Resource private DispatchService sourceRules; @jakarta.annotation.Resource private DispatchService sourceRules;
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
@@ -66,6 +67,10 @@ public class DispatchPickingService {
if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位");
long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType()); long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType());
List<DispatchSourceAllocation> relabelAllocations=List.of(); List<DispatchSourceAllocation> relabelAllocations=List.of();
// 原扫描已经通过业务门禁后才消费已确认资金冻结,之后任一库存失败整体回滚。
storageRent.consume(actor.boundWarehouseId(), new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
"DISPATCH", number, itemId, request.positionId(), request.packages(), request.cartonMark(), null,
request.idempotencyKey(), java.util.List.of(), null, request.productQuantity()));
if(nativeReturn) { if(nativeReturn) {
attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId());
returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId();
@@ -36,6 +36,7 @@ public class TransportTaskService {
private final TripOperationsEvidenceGateway files; private final TripOperationsEvidenceGateway files;
private final ObjectMapper json; private final ObjectMapper json;
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos; @jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService masterPhotos;
/** 复用已有资格、占用、原单及凭证能力。 */ /** 复用已有资格、占用、原单及凭证能力。 */
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection, public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts, FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
@@ -119,6 +120,20 @@ public class TransportTaskService {
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试"); || !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片"); if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
// 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。
var supplied=request.reviewPhotos();
var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE,
request.vehicleId(),supplied==null?List.of():supplied.vehicleFileIds());
var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,
request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds());
var license=request.masterLicenseFileId();
if(license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) {
var driver=drivers.detail(request.driverId(),null);
if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId());
}
request=new TransportTaskModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason(),
request.dispatchRecordVersion(),new com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments(
vehiclePhotos,driverPhotos,supplied==null?List.of():supplied.licenseFileIds()),license);
photos=reviewPhotos.validate(row,trip,request); photos=reviewPhotos.validate(row,trip,request);
} }
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason())); trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
@@ -46,6 +46,7 @@ public final class LastlegOrderRequests {
if (page == null || page == 0) page = 1L; if (page == null || page == 0) page = 1L;
if (size == null || size == 0) size = 20L; if (size == null || size == 0) size = 20L;
} }
} }
public record ContainerProgressQuery( public record ContainerProgressQuery(
@@ -100,6 +101,7 @@ public final class LastlegOrderRequests {
public record CreateFee( public record CreateFee(
@NotBlank @Size(max = 64) String feeItemCode, @NotBlank @Size(max = 64) String feeItemCode,
@NotBlank @Size(max = 200) String feeItemName, @NotBlank @Size(max = 200) String feeItemName,
@Size(max = 500) String feeRemark,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, @NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotBlank @Pattern(regexp = "CNY|USD") String currency, @NotBlank @Pattern(regexp = "CNY|USD") String currency,
@NotNull @DecimalMin(value = "0.01") BigDecimal amount, @NotNull @DecimalMin(value = "0.01") BigDecimal amount,
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.financefee.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
/** 海外费用只通过 IAM 内部接口读取客户主档,不跨库查询。 */
@FeignClient(name = "iam-service", contextId = "overseasFeeCustomerClient", url = "${baiyue.clients.iam.url:}")
public interface OverseasFeeCustomerClient {
/** 按客户精确查名,不限制启用状态,以保留停用客户的历史费用展示。 */
@GetMapping("/internal/iam/finance-customers")
ApiResponse<PageResult<Customer>> customers(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("page") long page, @RequestParam("size") long size,
@RequestParam("customerId") long customerId);
record Customer(Long id, String tenantName) { }
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.financefee.config;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService.RetryRecord;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** 费用系统时间按 UTC 存储;仅响应序列化声明时区,不修改业务录入时间和实体类型。 */
@Configuration
public class OverseasFeeResponseJsonConfiguration {
/** 费用接口目前直接返回实体,通过专属混入避免改变其他业务 LocalDateTime 的含义。 */
@Bean
public SimpleModule overseasFeeResponseTimeModule() {
var module = new SimpleModule("overseas-fee-response-time");
module.setMixInAnnotation(OverseasFeeSource.class, FeeTimes.class);
module.setMixInAnnotation(RetryRecord.class, RetryTimes.class);
return module;
}
abstract static class FeeTimes {
@JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getCreatedAt();
@JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getReviewedAt();
@JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getUpdatedAt();
}
abstract static class RetryTimes {
@JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime requestedAt();
}
/** 补充 UTC 标记而非偏移数值,浏览器可以按用户时区显示同一时刻。 */
public static class UtcTimeSerializer extends JsonSerializer<LocalDateTime> {
@Override public void serialize(LocalDateTime value, JsonGenerator output, SerializerProvider provider) throws IOException {
output.writeString(java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value.atOffset(ZoneOffset.UTC)));
}
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.financefee.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.financefee.service.FeeCatalogGateway;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 海外费用录入的只读项目候选,复用费用列表权限。 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources/fee-items")
public class PlatformOverseasFeeCatalogController {
private final FeeCatalogGateway catalog;
/** 返回当前平台可用于海外收费的公共费用项目。 */
@GetMapping
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
public ApiResponse<List<FeeCatalogGateway.FeeItem>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "RECEIVABLE") String direction) {
if (user == null || !Long.valueOf(0).equals(user.tenantId()) || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅平台人员可查询费用项目");
}
return ApiResponse.success(catalog.listActive(direction));
}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.financefee.controller; package com.baiyue.lastleg.financefee.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest;
@@ -10,6 +11,10 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.financefee.service.OverseasManualFeeService;
import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.RetryDeliveryRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -32,20 +37,26 @@ import java.util.List;
@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources") @RequestMapping("/api/platform/v1/last-leg/finance-fee-sources")
public class PlatformOverseasFeeSourceController { public class PlatformOverseasFeeSourceController {
private final OverseasFeeSourceService service; private final OverseasFeeSourceService service;
private final OverseasManualFeeService manual;
private final OverseasFeeDeliveryService delivery;
/** 查询海外费用当前版本。 */
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
public ApiResponse<List<OverseasFeeSource>> list(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<List<OverseasFeeSource>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@RequestParam(required = false) String businessType, @RequestParam(required = false) String businessType,
@RequestParam(required = false) String businessNo, @RequestParam(required = false) String businessNo,
@RequestParam(required = false) Long containerBookingId) { @RequestParam(required = false) Long containerBookingId) {
requirePlatform(user); requirePlatform(user);
return ApiResponse.success(service.list(customerId, businessType, businessNo, containerBookingId)); var result = manual.describe(service.list(customerId, businessType, businessNo, containerBookingId));
delivery.describe(result);
return ApiResponse.success(result);
} }
/** 查询真实柜下可计费客户。 */
@GetMapping("/container-customers") @GetMapping("/container-customers")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
public ApiResponse<List<ContainerCustomerOption>> listContainerCustomers( public ApiResponse<List<ContainerCustomerOption>> listContainerCustomers(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long containerBookingId) { @RequestParam long containerBookingId) {
@@ -53,47 +64,139 @@ public class PlatformOverseasFeeSourceController {
return ApiResponse.success(service.listContainerCustomers(containerBookingId)); return ApiResponse.success(service.listContainerCustomers(containerBookingId));
} }
/** 创建人工费用并开始锁定余额及授信。 */
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<OverseasFeeSource> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody SaveRequest request) { @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody SaveRequest request) {
requirePlatform(user); return ApiResponse.success(service.create(operator(user), key, request)); requirePlatform(user); return ApiResponse.success(manual.create(operator(user), key, request));
} }
/** 重新提交费用审核。 */
@PostMapping("/{sourceFeeId}/submit") @PostMapping("/{sourceFeeId}/submit")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> submit(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<OverseasFeeSource> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) { @PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
requirePlatform(user); return ApiResponse.success(service.submit(sourceFeeId, operator(user), request.idempotencyKey())); requirePlatform(user); return ApiResponse.success(manual.submit(sourceFeeId, operator(user), request.idempotencyKey()));
} }
/** 审核费用并推进对应结算。 */
@PostMapping("/{sourceFeeId}/review") @PostMapping("/{sourceFeeId}/review")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')") @PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<OverseasFeeSource> review(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<OverseasFeeSource> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ReviewRequest request) { @PathVariable long sourceFeeId, @Valid @RequestBody ReviewRequest request) {
requirePlatform(user); return ApiResponse.success(service.review(sourceFeeId, operator(user), request)); requirePlatform(user); return ApiResponse.success(manual.review(sourceFeeId, operator(user), operatorName(user), request));
} }
/** 提交账单费用的调整版本。 */
@PostMapping("/{sourceFeeId}/adjust") @PostMapping("/{sourceFeeId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody AdjustRequest request) { @PathVariable long sourceFeeId, @Valid @RequestBody AdjustRequest request) {
requirePlatform(user); return ApiResponse.success(service.adjust(sourceFeeId, operator(user), request)); requirePlatform(user); return ApiResponse.success(manual.adjust(sourceFeeId, operator(user), request));
} }
/** 申请作废已推送账单费用。 */
@PostMapping("/{sourceFeeId}/void") @PostMapping("/{sourceFeeId}/void")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')") @PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody VoidRequest request) { @PathVariable long sourceFeeId, @Valid @RequestBody VoidRequest request) {
requirePlatform(user); return ApiResponse.success(service.voidSource(sourceFeeId, operator(user), request)); requirePlatform(user); return ApiResponse.success(manual.voidSource(sourceFeeId, operator(user), request));
} }
/** 分页查询海外费用及审核、扣款进度。 */
@GetMapping("/page")
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
public ApiResponse<PageResult<OverseasFeeSource>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String businessType,
@RequestParam(required = false) String businessNo, @RequestParam(required = false) String feeItemCode,
@RequestParam(required = false) String reviewStatus, @RequestParam(required = false) String walletStatus,
@RequestParam(defaultValue = "RECEIVABLE") String direction,
@RequestParam(required = false) String processingStatus,
@RequestParam(defaultValue = "false") boolean storageRentOnly) {
requirePlatform(user);
var result = manual.page(page, size, customerId, businessType, businessNo, feeItemCode, reviewStatus, walletStatus, direction, processingStatus, storageRentOnly);
delivery.describe(result.records());
return ApiResponse.success(result);
}
/** 读取单条费用详情与当前真实可操作性。 */
@GetMapping("/{sourceFeeId}")
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
public ApiResponse<OverseasFeeSource> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId) {
requirePlatform(user);
var result = manual.detail(sourceFeeId); delivery.describe(List.of(result));
return ApiResponse.success(result);
}
/** 查询费用客户筛选选项。 */
@GetMapping("/customer-options")
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
public ApiResponse<List<ContainerCustomerOption>> customers(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user); return ApiResponse.success(manual.customers());
}
/** 查询本次服务业务单候选。 */
@GetMapping("/business-options")
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
public ApiResponse<List<BusinessOption>> businessOptions(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String businessType, @RequestParam(required = false) String customerId,
@RequestParam(required = false) String keyword) {
requirePlatform(user); return ApiResponse.success(manual.businessOptions(businessType, customerId, keyword));
}
/** 取消未收取人工费用并释放已冻结资金。 */
@PostMapping("/{sourceFeeId}/cancel")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
requirePlatform(user); return ApiResponse.success(manual.cancel(sourceFeeId, operator(user), request.idempotencyKey()));
}
/** 显式重试已经失败的冻结、扣款或释放指令。 */
@PostMapping("/{sourceFeeId}/retry-funding")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> retryFunding(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
requirePlatform(user); return ApiResponse.success(manual.retry(sourceFeeId, operator(user), auditOperator(user), request.idempotencyKey()));
}
/** 重试当前费用失败的原投递事件,不新建费用或重复扣款。 */
@PostMapping("/{sourceFeeId}/retry-delivery")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> retryDelivery(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody RetryDeliveryRequest request) {
requirePlatform(user); delivery.retry(sourceFeeId, auditOperator(user), request);
var result = manual.detail(sourceFeeId); delivery.describe(List.of(result));
return ApiResponse.success(result);
}
/** 查询单条费用历次重试及实际结果,权限与费用详情保持一致。 */
@GetMapping("/{sourceFeeId}/retries")
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
public ApiResponse<List<OverseasFeeDeliveryService.RetryRecord>> retries(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId) {
requirePlatform(user); return ApiResponse.success(delivery.history(sourceFeeId));
}
/** 操作人从认证身份取得,保留名称与ID,不能由浏览器指定。 */
private static String auditOperator(AuthenticatedUser user) {
String value = operatorName(user) + " [" + operator(user) + "]";
return value.length() <= 128 ? value : value.substring(0, 128);
}
/** 海外费用平台入口不接受客户身份。 */
private static void requirePlatform(AuthenticatedUser user) { private static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L)
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可操作海外费用");
} }
/** 记录当前审核或创建操作人。 */
private static String operator(AuthenticatedUser user) { private static String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId()); return user.userId() == null ? user.username() : String.valueOf(user.userId());
} }
/** 为审核留存平台操作人的名称快照,方便费用明细直接追溯。 */
private static String operatorName(AuthenticatedUser user) {
return user.displayName() == null || user.displayName().isBlank() ? user.username() : user.displayName().trim();
}
} }
@@ -17,11 +17,14 @@ public final class OverseasFeeSourceDtos {
public record ContainerCustomerOption(String customerId, String customerName) { } public record ContainerCustomerOption(String customerId, String customerName) { }
public record BusinessOption(Long businessId, String businessType, String businessNo, String customerId,
String customerName, Long warehouseId, Long orderId, String orderType) { }
public record SaveRequest( public record SaveRequest(
@NotBlank @Size(max = 128) String customerId, @NotBlank @Size(max = 128) String customerId,
@Size(max = 200) String customerName, @Size(max = 200) String customerName,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType, @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER") String businessType, @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER|WAREHOUSE_TRANSFER|RETURN_ORDER|DISCARD_ORDER") String businessType,
@Min(1) Long businessId, @Min(1) Long businessId,
@NotBlank @Size(max = 128) String businessNo, @NotBlank @Size(max = 128) String businessNo,
@Min(1) Long orderId, @Min(1) Long orderId,
@@ -33,11 +36,14 @@ public final class OverseasFeeSourceDtos {
@Min(1) Long warehouseId, @Min(1) Long warehouseId,
@NotBlank @Size(max = 64) String feeItemCode, @NotBlank @Size(max = 64) String feeItemCode,
@NotBlank @Size(max = 200) String feeItemName, @NotBlank @Size(max = 200) String feeItemName,
/** 业务人员补收或重复作业时填写的简短说明,和审核驳回原因分开保存。 */
@Size(max = 500) String feeRemark,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, @NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@Min(1) Long supplierId, @Min(1) Long supplierId,
@Size(max = 200) String supplierName, @Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory, @Size(max = 32) String supplierCategory,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, /** 应收可选 BILL(推送结算)或 WALLET(余额扣费);应付仍使用账期/已付。 */
@Pattern(regexp = "BILL|WALLET|ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt, LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, @Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo, @Size(max = 128) String paymentReferenceNo,
@@ -53,9 +59,16 @@ public final class OverseasFeeSourceDtos {
) { } ) { }
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { } public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
public record RetryDeliveryRequest(@NotNull @Min(1) Integer expectedVersion,
@NotBlank @Size(max = 64) String eventId,
@NotBlank @Pattern(regexp = "[0-9a-fA-F-]{36}") String idempotencyKey) { }
public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
@Size(max = 500) String reason, @Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { } @NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) Integer expectedVersion) {
/** 兼容既有人工费用调用,仓租审核另要求明确当前版本。 */
public ReviewRequest(String decision,String reason,String idempotencyKey){this(decision,reason,idempotencyKey,null);}
}
public record AdjustRequest(@NotNull @Min(1) Integer expectedVersion, public record AdjustRequest(@NotNull @Min(1) Integer expectedVersion,
@NotNull SaveRequest fee, @NotNull SaveRequest fee,
@NotBlank @Size(max = 128) String idempotencyKey) { } @NotBlank @Size(max = 128) String idempotencyKey) { }
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.financefee.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/** 人工费用的资金指令状态;实际余额和账本始终由 Finance 维护。 */
@Data
@TableName(value = "ll_finance_fee_funding", autoResultMap = true)
public class OverseasFeeFunding {
@TableId private Long sourceFeeId;
private String customerId;
private String collectionMode;
private String walletStatus;
private String walletBusinessNo;
private String commandEventId;
private String walletHoldId;
private String walletLedgerId;
private Long commandVersion;
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String errorMessage;
@TableField(typeHandler = JacksonTypeHandler.class) private Map<String, String> commandHistory;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.financefee.entity;
import lombok.Data;
import java.time.LocalDateTime;
/** 复用本库人工重试审计;查询字段保留每次操作对应的原事件与资金回执。 */
@Data
public class OverseasFeeRetryAudit {
private String requestId;
private String producerTable;
private String entryId;
private String eventId;
private String eventType;
private String payloadHash;
private String operatorName;
private String reason;
private Integer previousFailures;
private String previousError;
private LocalDateTime requestedAt;
private String currentStatus;
private String currentError;
private String walletResult;
private LocalDateTime walletCompletedAt;
private String attemptOutcome;
}
@@ -15,6 +15,12 @@ import java.util.List;
@Data @Data
@TableName(value = "ll_finance_fee_source", autoResultMap = true) @TableName(value = "ll_finance_fee_source", autoResultMap = true)
public class OverseasFeeSource { public class OverseasFeeSource {
/** 自动仓租本次出库依据;查询时装配,不复制库存和资金账本。 */
@TableField(exist = false) private RentDetail storageRent;
public record RentLine(long lotId, java.time.OffsetDateTime inboundAt, java.math.BigDecimal quantity, long days,
java.math.BigDecimal calculatedAmount, String sourceBusinessType, String sourceBusinessNo, String batchNo) { }
public record RentDetail(String outboundNo, java.time.OffsetDateTime outboundAt,
java.math.BigDecimal calculatedAmount, java.math.BigDecimal chargedAmount, java.util.List<RentLine> lines) { }
@TableId(type = IdType.INPUT) private Long id; @TableId(type = IdType.INPUT) private Long id;
private Long sourceFeeId; private Long sourceFeeId;
private Integer sourceVersion; private Integer sourceVersion;
@@ -23,6 +29,7 @@ public class OverseasFeeSource {
private String sourceType; private String sourceType;
private String sourceNo; private String sourceNo;
private String businessType; private String businessType;
@TableField(exist = false) private String displayBusinessType;
private Long businessId; private Long businessId;
private String businessNo; private String businessNo;
private Long orderId; private Long orderId;
@@ -34,6 +41,8 @@ public class OverseasFeeSource {
private Long warehouseId; private Long warehouseId;
private String feeItemCode; private String feeItemCode;
private String feeItemName; private String feeItemName;
/** 费用业务说明,不用于记录审核结论。 */
private String feeRemark;
private String direction; private String direction;
private Long supplierId; private Long supplierId;
private String supplierName; private String supplierName;
@@ -50,6 +59,8 @@ public class OverseasFeeSource {
private BigDecimal amount; private BigDecimal amount;
private String originalCurrency; private String originalCurrency;
private BigDecimal originalAmount; private BigDecimal originalAmount;
/** 免费仓租显式写入空汇率,避免存量库默认1被误当美元调价汇率。 */
@TableField(insertStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private BigDecimal exchangeRate; private BigDecimal exchangeRate;
private String exchangeRateMonth; private String exchangeRateMonth;
private BigDecimal chargeableQuantity; private BigDecimal chargeableQuantity;
@@ -61,8 +72,21 @@ public class OverseasFeeSource {
private String publicationStatus; private String publicationStatus;
private String sourceStatus; private String sourceStatus;
@TableField(exist = false) private String voidStatus; @TableField(exist = false) private String voidStatus;
@TableField(exist = false) private String collectionMode;
@TableField(exist = false) private String walletStatus;
@TableField(exist = false) private String walletErrorMessage;
@TableField(exist = false) private String deliveryStatus;
@TableField(exist = false) private String deliveryKind;
@TableField(exist = false) private String deliveryEventId;
@TableField(exist = false) private String deliveryErrorMessage;
@TableField(exist = false) private List<String> allowedActions;
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String reviewReason; private String reviewReason;
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String reviewerId; private String reviewerId;
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String reviewerName;
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private LocalDateTime reviewedAt; private LocalDateTime reviewedAt;
private String createdBy; private String createdBy;
private String updatedBy; private String updatedBy;
@@ -0,0 +1,104 @@
package com.baiyue.lastleg.financefee.mapper;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.SelectProvider;
import java.util.List;
import java.util.Map;
/** 只读取 Lastleg 自有业务事实,客户条件不可省略。 */
@Mapper
public interface OverseasFeeBusinessMapper {
/** 查询业务记录是否属于当前客户和单号。 */
@SelectProvider(type = Sql.class, method = "owned")
int countOwned(@Param("type") String type, @Param("id") long id,
@Param("customerId") String customerId, @Param("businessNo") String businessNo);
/** 补充关联也必须属于相同客户,不能夹带其他客户的订单或入仓信息。 */
@SelectProvider(type = Sql.class, method = "customerOwned")
int countCustomerOwned(@Param("type") String type, @Param("id") long id, @Param("customerId") String customerId);
/** 校验入仓码属于对应客户计划。 */
@Select("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=#{id} AND customer_id=#{customerId} AND inbound_code=#{code}")
int countInbound(@Param("id") long id, @Param("customerId") String customerId, @Param("code") String code);
/** 校验真实柜存在。 */
@Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId}")
int countContainer(@Param("bookingId") String bookingId);
/** 拒绝同一来源柜中不一致的真实柜号。 */
@Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId} AND (container_no IS NULL OR TRIM(container_no)='' OR BINARY TRIM(container_no) <> BINARY #{containerNo})")
int countInconsistentContainer(@Param("bookingId") String bookingId, @Param("containerNo") String containerNo);
/** 校验费用客户确实属于此柜。 */
@Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId} AND customer_id=#{customerId}")
int countContainerCustomer(@Param("bookingId") String bookingId, @Param("customerId") String customerId);
/** 按来源柜一次聚合客户。 */
@Select("SELECT customer_id,MAX(NULLIF(TRIM(customer_name),'')) customer_name FROM ll_order WHERE source_container_booking_id=#{bookingId} GROUP BY customer_id ORDER BY customer_id")
List<ContainerCustomerOption> containerCustomers(@Param("bookingId") String bookingId);
/** 客户筛选只取本服务已有真实业务和费用客户。 */
@Select("SELECT customer_id,MAX(customer_name) customer_name FROM (SELECT customer_id,customer_name FROM ll_order UNION ALL SELECT customer_id,customer_name FROM ll_finance_fee_source"
+ " UNION ALL SELECT customer_id,NULL FROM ll_warehouse_transfer_order UNION ALL SELECT customer_id,NULL FROM ll_return_order"
+ " UNION ALL SELECT customer_id,NULL FROM ll_us_truck_order UNION ALL SELECT customer_id,NULL FROM ll_local_looseboard_order"
+ " UNION ALL SELECT customer_id,NULL FROM ll_relabel_application UNION ALL SELECT customer_id,NULL FROM ll_cargo_discard_application"
+ " UNION ALL SELECT customer_id,NULL FROM ll_inbound_plan UNION ALL SELECT customer_id,NULL FROM ll_delivery_order) c"
+ " WHERE customer_id IS NOT NULL AND TRIM(customer_id)!='' GROUP BY customer_id ORDER BY customer_id LIMIT 1000")
List<ContainerCustomerOption> customers();
/** 头程附加费只认本次服务订单的类型,不能沿货物最初来源推断。 */
@Select("SELECT order_type FROM ll_order WHERE id=#{id} AND customer_id=#{customerId}")
String orderType(@Param("id") long id, @Param("customerId") String customerId);
/** 换标履约订单登记费用时,定位同客户的真实换标申请。 */
@Select("SELECT a.id business_id,'RELABEL_ORDER' business_type,a.relabel_no business_no,a.customer_id,"
+ "o.customer_name,o.overseas_warehouse_id warehouse_id,o.id order_id,o.order_type"
+ " FROM ll_relabel_application a JOIN ll_order o ON o.id=a.lastleg_order_id AND o.customer_id=a.customer_id"
+ " WHERE o.id=#{id} AND o.customer_id=#{customerId} AND o.order_type='RELABEL_APPLICATION'")
BusinessOption relabelForOrder(@Param("id") long id, @Param("customerId") String customerId);
/** 批量按真实客户和订单类型识别换标费用,不根据单号前缀猜测。 */
@Select("<script>SELECT DISTINCT f.source_fee_id FROM ll_finance_fee_source f"
+ " JOIN ll_order o ON f.business_type='LASTLEG_ORDER' AND o.id=f.business_id AND o.customer_id=f.customer_id"
+ " WHERE o.order_type='RELABEL_APPLICATION' AND f.source_fee_id IN"
+ " <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<Long> relabelFeeIds(@Param("ids") List<Long> ids);
/** 按客户与关键字返回真实业务候选。 */
@SelectProvider(type = Sql.class, method = "options")
List<BusinessOption> options(@Param("type") String type, @Param("customerId") String customerId,
@Param("keyword") String keyword);
final class Sql {
/** 业务表和单号字段只由服务端白名单解析,禁止请求传入 SQL 标识符。 */
private static String[] table(String type) {
return switch (type) {
case "OVERSEAS_INBOUND" -> new String[]{"ll_inbound_plan", "plan_no", "NULL", "NULL"};
case "OVERSEAS_PROCUREMENT" -> new String[]{"ll_overseas_procurement_order", "order_no", "NULL", "NULL"};
case "PROCUREMENT_FEE" -> new String[]{"ll_overseas_procurement_fee", "fee_no", "NULL", "NULL"};
case "US_TRUCK" -> new String[]{"ll_us_truck_order", "order_no", "NULL", "NULL"};
case "LOCAL_LOOSEBOARD" -> new String[]{"ll_local_looseboard_order", "order_no", "NULL", "NULL"};
case "LASTLEG_ORDER" -> new String[]{"ll_order", "order_no", "t.overseas_warehouse_id", "t.order_type"};
case "DELIVERY_ORDER" -> new String[]{"ll_delivery_order", "delivery_no", "NULL", "NULL"};
case "RELABEL_ORDER" -> new String[]{"ll_relabel_application", "relabel_no", "NULL", "NULL"};
case "WAREHOUSE_TRANSFER" -> new String[]{"ll_warehouse_transfer_order", "transfer_no", "t.operating_warehouse_id", "NULL"};
case "RETURN_ORDER" -> new String[]{"ll_return_order", "return_no", "t.overseas_warehouse_id", "NULL"};
case "DISCARD_ORDER" -> new String[]{"ll_cargo_discard_application", "discard_no", "NULL", "NULL"};
default -> throw new IllegalArgumentException("不支持的费用业务类型");
};
}
/** 生成精确业务归属查询。 */
public static String owned(Map<String, Object> args) {
String[] table = table((String) args.get("type"));
return "SELECT COUNT(*) FROM " + table[0] + " WHERE id=#{id} AND customer_id=#{customerId} AND " + table[1] + "=#{businessNo}";
}
/** 只核对补充引用的主键及客户,业务主关联另行核对单号。 */
public static String customerOwned(Map<String, Object> args) {
String[] table = table((String) args.get("type"));
return "SELECT COUNT(*) FROM " + table[0] + " WHERE id=#{id} AND customer_id=#{customerId}";
}
/** 候选限制最多五十条;输入只作为绑定参数。 */
public static String options(Map<String, Object> args) {
String type = (String) args.get("type");
String[] table = table(type);
return "<script>SELECT t.id business_id,'" + type + "' business_type,t." + table[1] + " business_no,t.customer_id,"
+ "(SELECT MAX(o.customer_name) FROM ll_order o WHERE o.customer_id=t.customer_id) customer_name,"
+ table[2] + " warehouse_id," + ("LASTLEG_ORDER".equals(type) ? "t.id" : "NULL") + " order_id," + table[3] + " order_type"
+ " FROM " + table[0] + " t WHERE 1=1 <if test='customerId != null'>AND t.customer_id=#{customerId}</if>"
+ " <if test='keyword != null'>AND t." + table[1] + " LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY t.id DESC LIMIT 50</script>";
}
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.financefee.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface OverseasFeeFundingMapper extends BaseMapper<OverseasFeeFunding> {
/** 锁定费用资金编排,避免审核、取消和异步回执相互覆盖。 */
@Select("SELECT * FROM ll_finance_fee_funding WHERE source_fee_id=#{sourceFeeId} FOR UPDATE")
@org.apache.ibatis.annotations.ResultMap("mybatis-plus_OverseasFeeFunding")
OverseasFeeFunding lock(@Param("sourceFeeId") long sourceFeeId);
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.financefee.mapper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
/** 重试只重置原事件状态;费用、金额及事件载荷不可修改。 */
@Mapper
public interface OverseasFeeRetryMapper {
@Select("SELECT * FROM ll_event_outbox WHERE event_key=#{key} FOR UPDATE")
LastlegEventOutbox lockEvent(@Param("key") String key);
@Select("SELECT * FROM event_delivery_retry_audit WHERE request_id=#{requestId}")
OverseasFeeRetryAudit request(@Param("requestId") String requestId);
/** 仅重新排队失败事件,不抢占发送租约;清除旧失败以禁止连续重复点击。 */
@Update("UPDATE ll_event_outbox SET status='PENDING',retry_count=0,next_retry_at=#{now},locked_by=NULL,locked_at=NULL,last_error=NULL WHERE id=#{id} AND (status='DEAD' OR (status='PENDING' AND NULLIF(TRIM(last_error),'') IS NOT NULL))")
int requeue(@Param("id") long id, @Param("now") LocalDateTime now);
@Insert("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(#{requestId},#{producerTable},#{entryId},#{eventId},#{eventType},#{payloadHash},#{operatorName},#{reason},#{previousFailures},#{previousError},#{requestedAt})")
int insert(OverseasFeeRetryAudit audit);
/** 跨版本历史按稳定费用身份归集;事件和记录标识按字节精确关联,避免表间排序规则冲突或串记录。 */
@Select("SELECT a.*,e.status current_status,COALESCE((SELECT newer.previous_error FROM event_delivery_retry_audit newer WHERE newer.event_id=a.event_id AND newer.requested_at > a.requested_at ORDER BY newer.requested_at LIMIT 1),e.last_error) current_error,i.payload_json wallet_result,i.processed_at wallet_completed_at, "
+ "(SELECT d.outcome FROM event_delivery_attempt d WHERE CAST(d.event_id AS BINARY)=CAST(a.event_id AS BINARY) AND d.started_at >= a.requested_at "
+ "AND d.started_at < COALESCE((SELECT MIN(newer.requested_at) FROM event_delivery_retry_audit newer WHERE newer.event_id=a.event_id AND newer.requested_at > a.requested_at),'9999-12-31 23:59:59') ORDER BY d.started_at DESC,d.attempt_id DESC LIMIT 1) attempt_outcome "
+ "FROM event_delivery_retry_audit a JOIN ll_event_outbox e ON a.producer_table='ll_event_outbox' AND CAST(a.entry_id AS BINARY)=CAST(CONCAT('',e.id) AS BINARY) "
+ "LEFT JOIN ll_wallet_event_inbox i ON CAST(i.command_event_id AS BINARY)=CAST(a.event_id AS BINARY) AND i.status='PROCESSED' "
+ "WHERE e.customer_id=#{customer} AND ((e.event_type='BusinessFeeSourceChanged' AND e.aggregate_type=#{sourceType} AND e.aggregate_id=#{sourceId}) "
+ "OR (e.event_type='WalletCommandRequested' AND e.aggregate_type='STORAGE_RENT' AND CAST(e.aggregate_id AS BINARY) IN (SELECT CAST(CONCAT('',r.id) AS BINARY) FROM ll_storage_rent_preparation r WHERE r.source_fee_id=#{sourceId} AND r.customer_id=#{customer})) OR (e.event_type='WalletCommandRequested' AND e.aggregate_type='OVERSEAS_MANUAL_FEE' AND e.aggregate_id LIKE CONCAT(#{sourceId},'-%'))) "
+ "ORDER BY a.requested_at DESC,a.request_id DESC")
List<OverseasFeeRetryAudit> history(@Param("customer") String customer, @Param("sourceType") String sourceType, @Param("sourceId") String sourceId);
}
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.financefee.mapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.ResultMap;
import java.util.List;
import java.util.Map;
@Mapper
public interface OverseasManualFeeQueryMapper {
/** 查询最新费用版本的数量,所有筛选与分页查询一致。 */
@SelectProvider(type = Sql.class, method = "count")
long count(@Param("query") Map<String, Object> query);
/** 返回稳定排序的当前版本,避免财务费用列表无限加载。 */
@SelectProvider(type = Sql.class, method = "page")
@ResultMap("com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper.mybatis-plus_OverseasFeeSource")
List<OverseasFeeSource> page(@Param("query") Map<String, Object> query);
/** 同客户、业务单及方向核对现有项目;已驳回记录也保留,允许说明后重新登记。 */
@Select("SELECT COUNT(*) FROM ll_finance_fee_source f WHERE f.customer_id=#{customer}"
+ " AND f.business_type=#{type} AND f.business_id=#{businessId} AND f.direction=#{direction}"
+ " AND f.fee_item_code=#{code} AND f.source_status='ACTIVE' AND f.source_fee_id != #{exclude}"
+ " AND NOT EXISTS(SELECT 1 FROM ll_finance_fee_source n WHERE n.source_fee_id=f.source_fee_id AND n.source_version>f.source_version)")
int sameProject(@Param("customer") String customer, @Param("type") String type, @Param("businessId") long businessId,
@Param("direction") String direction, @Param("code") String code, @Param("exclude") long exclude);
/** 只拦截旧自动链的同项目,新人工费用允许按实际服务分次登记。 */
@Select("<script>SELECT COUNT(*) FROM ll_finance_fee_source f WHERE f.customer_id=#{customer} AND f.direction='RECEIVABLE'"
+ " AND f.fee_item_code IN ('RELABEL_SERVICE','DELIVERY_FEE','LOADING_FEE','APPOINTMENT_FEE','DESTUFF_PALLET') AND f.source_status='ACTIVE' AND f.review_status IN ('PENDING','APPROVED') AND f.source_fee_id != #{exclude}"
+ " AND NOT EXISTS(SELECT 1 FROM ll_finance_fee_source n WHERE n.source_fee_id=f.source_fee_id AND n.source_version>f.source_version)"
+ " AND NOT EXISTS(SELECT 1 FROM ll_finance_fee_funding w WHERE w.source_fee_id=f.source_fee_id)"
+ " AND ((f.business_type=#{type} AND f.business_id=#{id})"
+ "<if test='type == &quot;LASTLEG_ORDER&quot;'> OR (f.order_id=#{id} AND f.fee_item_code='RELABEL_SERVICE')</if>)"
+ " AND (f.fee_item_code=#{code} OR f.fee_item_name=#{name}"
+ "<if test='code == &quot;FEE_TYPE_071&quot;'> OR f.fee_item_code='RELABEL_SERVICE'</if>"
+ "<if test='code == &quot;FEE_TYPE_075&quot;'> OR f.fee_item_code='DELIVERY_FEE'</if>)</script>")
int legacyProject(@Param("customer") String customer,@Param("type") String type,@Param("id") long id,
@Param("code") String code,@Param("name") String name,@Param("exclude") long exclude);
/** 尚未生成费用来源的旧换标冻结及已扣快照也必须防重。 */
@Select("<script>SELECT COUNT(*) FROM ll_relabel_application WHERE customer_id=#{customer} AND charge_amount>0"
+ " AND wallet_status NOT IN ('NOT_REQUIRED','RELEASED')"
+ "<choose><when test='type == &quot;RELABEL_ORDER&quot;'> AND id=#{id}</when>"
+ "<otherwise> AND lastleg_order_id=#{id}</otherwise></choose></script>")
int legacyRelabel(@Param("customer") String customer,@Param("type") String type,@Param("id") long id);
final class Sql {
/** 绑定动态筛选,不拼接任何请求值。 */
private static String where() {
return " FROM ll_finance_fee_source f LEFT JOIN ll_finance_fee_funding w ON w.source_fee_id=f.source_fee_id"
+ " WHERE NOT EXISTS (SELECT 1 FROM ll_finance_fee_source n WHERE n.source_fee_id=f.source_fee_id AND n.source_version>f.source_version)"
+ "<if test='query.customerId != null'> AND f.customer_id=#{query.customerId}</if>"
+ "<if test='query.businessType != null'> AND (CASE WHEN f.business_type='LASTLEG_ORDER'"
+ " AND EXISTS(SELECT 1 FROM ll_order o WHERE o.id=f.business_id AND o.customer_id=f.customer_id"
+ " AND o.order_type='RELABEL_APPLICATION') THEN 'RELABEL_ORDER' ELSE f.business_type END)=#{query.businessType}</if>"
+ "<if test='query.businessNo != null'> AND f.business_no LIKE CONCAT('%',#{query.businessNo},'%')</if>"
+ "<if test='query.feeItemCode != null'> AND f.fee_item_code=#{query.feeItemCode}</if>"
// 分页和总数共用来源过滤,人工同名仓储费不混入出库仓租记录。
+ "<if test='query.storageRentOnly == true'> AND EXISTS(SELECT 1 FROM ll_storage_rent_preparation r WHERE r.source_fee_id=f.source_fee_id AND r.customer_id=f.customer_id AND r.consumed_at IS NOT NULL)</if>"
+ "<if test='query.reviewStatus != null'> AND f.review_status=#{query.reviewStatus}</if>"
+ "<if test='query.walletStatus != null'> AND (w.wallet_status=#{query.walletStatus}"
+ "<if test='query.walletStatus == &quot;SETTLED&quot;'> OR (w.source_fee_id IS NULL AND f.wallet_settlement IS NOT NULL)</if>)</if>"
+ "<if test='query.direction != null'> AND f.direction=#{query.direction}</if>"
+ "<if test='query.processingStatus != null'> AND (" + processingStatus() + ")=#{query.processingStatus}</if>";
}
/** 按当前版本唯一事件归并费用状态,列表总数与分页共用同一表达式。 */
private static String processingStatus() {
String eventKey = "CASE WHEN f.review_status='APPROVED' AND f.publication_status='PUBLISHED' THEN "
+ "CONCAT('BusinessFeeSourceChanged:LASTLEG:',f.source_type,':',f.source_fee_id,':',f.source_version,"
+ "CASE WHEN f.source_version=1 THEN ':PUBLISHED' ELSE ':ADJUSTED' END)"
+ " WHEN w.collection_mode='WALLET' AND w.wallet_status IN ('FREEZE_PENDING','SETTLE_PENDING','RELEASE_PENDING') THEN "
+ "CONCAT('WALLET_COMMAND:',f.customer_id,':',CASE WHEN f.created_by='STORAGE_RENT' AND f.fee_item_code='FEE_TYPE_077' THEN 'STORAGE_RENT' ELSE 'OVERSEAS_MANUAL_FEE' END,':',w.wallet_business_no,':',"
+ "CASE WHEN f.created_by='STORAGE_RENT' AND f.fee_item_code='FEE_TYPE_077' AND w.wallet_status='SETTLE_PENDING' THEN 'SETTLE_ACTUAL' ELSE REPLACE(w.wallet_status,'_PENDING','') END,':',w.command_version) ELSE NULL END";
String delivery = "(SELECT e.status FROM ll_event_outbox e WHERE e.event_key=(" + eventKey + ") AND e.customer_id=f.customer_id)";
String deliveryFailed = "EXISTS(SELECT 1 FROM ll_event_outbox e WHERE e.event_key=(" + eventKey + ") AND e.customer_id=f.customer_id "
+ "AND e.status IN ('PENDING','PROCESSING') AND NULLIF(TRIM(e.last_error),'') IS NOT NULL)";
// 费用已终结不代表资金已释放;补偿失败须能从失败筛选中找到。
return "CASE WHEN w.wallet_status='RELEASE_FAILED' THEN 'FAILED'"
+ " WHEN w.wallet_status IN ('FREEZE_PENDING','RELEASE_PENDING') AND (" + delivery + "='DEAD' OR " + deliveryFailed + ") THEN 'FAILED'"
+ " WHEN w.wallet_status IN ('FREEZE_PENDING','RELEASE_PENDING') THEN 'PROCESSING'"
+ " WHEN f.source_status IN ('VOIDED','SUPERSEDED','CANCELLED') OR f.review_status='REJECTED' THEN 'CLOSED'"
+ " WHEN f.direction='RECEIVABLE' AND (w.wallet_status='SETTLED' OR f.wallet_settlement IS NOT NULL) THEN 'COMPLETED'"
+ " WHEN w.wallet_status IN ('FREEZE_FAILED','SETTLE_FAILED','RELEASE_FAILED') OR " + delivery + "='DEAD' OR " + deliveryFailed + " THEN 'FAILED'"
+ " WHEN w.wallet_status IN ('FREEZE_PENDING','SETTLE_PENDING','RELEASE_PENDING') OR " + delivery + " IN ('PENDING','PROCESSING') THEN 'PROCESSING'"
+ " WHEN w.wallet_status='SETTLED' OR f.wallet_settlement IS NOT NULL OR f.publication_status='PUBLISHED' THEN 'COMPLETED' ELSE 'WAITING' END";
}
/** 生成同口径总数查询。 */
public static String count() { return "<script>SELECT COUNT(*)" + where() + "</script>"; }
/** 生成稳定分页查询。 */
public static String page() {
return "<script>SELECT f.*" + where() + " ORDER BY f.created_at DESC,f.id DESC LIMIT #{query.size} OFFSET #{query.offset}</script>";
}
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.financefee.repository;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeBusinessMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class OverseasFeeBusinessRepository {
private final OverseasFeeBusinessMapper mapper;
/** 核对真实业务客户与单号。 */
public int owned(String type, long id, String customer, String no) { return mapper.countOwned(type, id, customer, no); }
/** 补充业务引用也需核对客户,不能只校验主关联。 */
public int customerOwned(String type, long id, String customer) { return mapper.countCustomerOwned(type, id, customer); }
/** 核对入仓码。 */
public int inbound(long id, String customer, String code) { return mapper.countInbound(id, customer, code); }
/** 读取真实柜数量。 */
public int container(String id) { return mapper.countContainer(id); }
/** 核对柜号一致性。 */
public int inconsistentContainer(String id, String no) { return mapper.countInconsistentContainer(id, no); }
/** 核对柜内客户。 */
public int containerCustomer(String id, String customer) { return mapper.countContainerCustomer(id, customer); }
/** 一次读取柜内客户。 */
public List<ContainerCustomerOption> containerCustomers(String id) { return mapper.containerCustomers(id); }
/** 读取费用筛选客户。 */
public List<ContainerCustomerOption> customers() { return mapper.customers(); }
/** 读取本次业务订单类型。 */
public String orderType(long id, String customer) { return mapper.orderType(id, customer); }
/** 从履约主键解析实际换标单,保留客户隔离。 */
public BusinessOption relabelForOrder(long id, String customer) { return mapper.relabelForOrder(id, customer); }
/** 空列表不查询,费用展示采用批量业务事实。 */
public List<Long> relabelFeeIds(List<Long> ids) { return ids.isEmpty() ? List.of() : mapper.relabelFeeIds(ids); }
/** 返回真实业务候选。 */
public List<BusinessOption> options(String type, String customer, String keyword) { return mapper.options(type, customer, keyword); }
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.financefee.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeRetryMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/** 原事件及人工重试审计的数据访问,不接触客户余额或其他服务数据库。 */
@Repository
@RequiredArgsConstructor
public class OverseasFeeDeliveryRepository {
private final LastlegEventOutboxMapper events;
private final OverseasFeeRetryMapper retries;
public List<LastlegEventOutbox> events(Collection<String> keys) { return keys.isEmpty() ? List.of() : events.selectList(new LambdaQueryWrapper<LastlegEventOutbox>().in(LastlegEventOutbox::getEventKey, keys)); }
public LastlegEventOutbox event(String key) { return events.findByEventKey(key); }
public LastlegEventOutbox lock(String key) { return retries.lockEvent(key); }
public OverseasFeeRetryAudit request(String key) { return retries.request(key); }
public int requeue(long id, LocalDateTime now) { return retries.requeue(id, now); }
public void insert(OverseasFeeRetryAudit audit) { retries.insert(audit); }
public List<OverseasFeeRetryAudit> history(String customer, String sourceType, long sourceId) { return retries.history(customer, sourceType, String.valueOf(sourceId)); }
}
@@ -19,9 +19,13 @@ public class OverseasFeeOutboxRepository {
public boolean prerequisitePublished(LastlegEventOutbox row) { return cancellation.prerequisitePublished(row); } public boolean prerequisitePublished(LastlegEventOutbox row) { return cancellation.prerequisitePublished(row); }
/** 仅投递需结算的费用,已确认余额扣款的应收不再发送,避免无效重试占满队列。 */
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime stale) { public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime stale) {
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>() return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) .eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
.apply("NOT EXISTS (SELECT 1 FROM ll_finance_fee_source collected WHERE collected.source_fee_id=ll_event_outbox.aggregate_id "
+ "AND collected.source_type=ll_event_outbox.aggregate_type AND collected.customer_id=ll_event_outbox.customer_id "
+ "AND collected.direction='RECEIVABLE' AND collected.wallet_settlement IS NOT NULL)")
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.financefee.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeFundingMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasManualFeeQueryMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/** 人工费用读写适配,不持有客户余额。 */
@Repository
@RequiredArgsConstructor
public class OverseasManualFeeRepository {
private final OverseasFeeSourceMapper fees;
private final OverseasFeeFundingMapper funding;
private final OverseasManualFeeQueryMapper queries;
private final OverseasFeeCommandMapper commands;
/** 读取操作幂等记录,避免重试审核重复发资金指令。 */
public OverseasFeeCommand command(String operator, String action, String key) { return commands.find(operator, action, key); }
/** 保存业务操作幂等结果。 */
public void insertCommand(OverseasFeeCommand value) { commands.insert(value); }
/** 统计同筛选条件下的费用。 */
public long count(Map<String, Object> query) { return queries.count(query); }
/** 查询费用分页。 */
public List<OverseasFeeSource> page(Map<String, Object> query) { return queries.page(query); }
/** 重复收费仅要求解释实际服务,不对同单项目设置唯一收费限制。 */
public boolean hasSameProject(OverseasFeeSource fee) {
return queries.sameProject(fee.getCustomerId(), fee.getBusinessType(), fee.getBusinessId(),
fee.getDirection(), fee.getFeeItemCode(), fee.getSourceFeeId()) > 0;
}
/** 核对相同服务项目是否已经被旧自动应收占用。 */
public int legacyProject(String customer,String type,long id,String code,String name,long exclude) {
return queries.legacyProject(customer,type,id,code,name,exclude);
}
/** 读取旧换标资金占用,避免新旧两套资金重复扣款。 */
public int legacyRelabel(String customer,String type,long id) { return queries.legacyRelabel(customer,type,id); }
/** 获取费用最新版本。 */
public OverseasFeeSource current(long id) {
return fees.selectOne(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(OverseasFeeSource::getSourceFeeId, id).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1"));
}
/** 锁定费用后才能读取或变更资金状态,所有写入口保持相同锁顺序。 */
public OverseasFeeSource lockFee(long id) { return fees.lockCurrent(id); }
/** 保存本地费用状态,不代表完成钱包扣款。 */
public void updateFee(OverseasFeeSource fee) { fees.updateById(fee); }
/** 仓租调整保留每个金额版本及其说明,旧版本不覆盖。 */
public void insertFee(OverseasFeeSource fee) { fees.insert(fee); }
/** 查询已有资金编排。 */
public OverseasFeeFunding funding(long id) { return funding.selectById(id); }
/** 锁定资金编排。 */
public OverseasFeeFunding lockFunding(long id) { return funding.lock(id); }
/** 批量加载列表资金状态,避免逐条查询。 */
public List<OverseasFeeFunding> funding(Collection<Long> ids) {
return ids.isEmpty() ? List.of() : funding.selectBatchIds(ids);
}
/** 首次创建资金编排。 */
public void insertFunding(OverseasFeeFunding value) { funding.insert(value); }
/** 保存资金指令状态及已核验回执。 */
public void updateFunding(OverseasFeeFunding value) { funding.updateById(value); }
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.client.FirstlegFeeCatalogClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
/** 海外收费复用公共项目;目录不可用时拒绝新录费,不回退自由输入。 */
@Service
public class FeeCatalogGateway {
private final FirstlegFeeCatalogClient client;
private final String token;
/** 注入只读目录客户端及服务身份。 */
public FeeCatalogGateway(FirstlegFeeCatalogClient client,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.client = client;
this.token = token;
}
/** 返回真实启用的海外仓应收项目,默认价币种固定为人民币。 */
public List<FeeItem> listActiveReceivable() {
return listActive("RECEIVABLE");
}
/** 按收付方向返回当前启用项目,拒绝未知方向。 */
public List<FeeItem> listActive(String direction) {
if (!List.of("RECEIVABLE", "PAYABLE").contains(direction)) {
throw new BizException(ResultCode.BAD_REQUEST, "收付方向无效");
}
requireConfigured();
List<FirstlegFeeCatalogClient.Item> items = requireData(client.list(token, direction));
return items.stream().map(item -> new FeeItem(item.code(), item.name(), item.defaultPrice())).toList();
}
/** 校验项目编码、名称快照和收费方向,防止伪造项目名写入财务。 */
public void requireActive(String code, String name, String direction) {
requireConfigured();
FirstlegFeeCatalogClient.Item item = requireData(client.detail(token, code, direction));
if (!Objects.equals(code, item.code()) || !Objects.equals(name, item.name())) {
throw new BizException(ResultCode.CONFLICT, "费用项目已变更,请重新选择");
}
}
/** 未配置内部服务身份时禁止发送匿名目录请求。 */
private void requireConfigured() {
if (token == null || token.isBlank()) {
throw new BizException(ResultCode.CONFLICT, "费用项目服务未就绪");
}
}
/** 检查业务响应,不能把远端拒绝误认为空目录或有效项目。 */
private static <T> T requireData(ApiResponse<T> response) {
if (response == null || !"200".equals(response.code()) || response.data() == null) {
throw new BizException(ResultCode.CONFLICT, "费用项目不可用,请刷新后重试");
}
return response.data();
}
public record FeeItem(String code, String name, BigDecimal defaultPrice) { }
}
@@ -8,7 +8,7 @@ import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate; import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -17,22 +17,24 @@ import java.util.List;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class OverseasFeeBusinessGuard { public class OverseasFeeBusinessGuard {
private final JdbcTemplate jdbc; private final OverseasFeeBusinessRepository repository;
@Resource private DomesticWarehouseDirectoryClient warehouses; @Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
/** 按真实来源柜一次读取客户候选。 */
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) { public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
List<ContainerCustomerOption> customers = jdbc.query( return repository.containerCustomers(String.valueOf(containerBookingId));
"SELECT customer_id,CASE WHEN COUNT(DISTINCT NULLIF(TRIM(customer_name),''))=1 "
+ "THEN MAX(NULLIF(TRIM(customer_name),'')) ELSE NULL END AS customer_name "
+ "FROM ll_order WHERE source_container_booking_id=? GROUP BY customer_id ORDER BY customer_id",
(result, rowNum) -> new ContainerCustomerOption(
result.getString("customer_id"), result.getString("customer_name")),
String.valueOf(containerBookingId));
return customers;
} }
/** 校验费用与当前服务业务单的客户、单号一致。 */
public void requireOwned(SaveRequest fee) { public void requireOwned(SaveRequest fee) {
if (fee.orderId() != null && repository.customerOwned("LASTLEG_ORDER", fee.orderId(), fee.customerId().trim()) != 1)
throw bad("关联订单不属于该客户");
if (fee.inboundId() != null && repository.customerOwned("OVERSEAS_INBOUND", fee.inboundId(), fee.customerId().trim()) != 1)
throw bad("关联入仓计划不属于该客户");
if (fee.inboundId() != null && !blank(fee.inboundCode())
&& repository.inbound(fee.inboundId(), fee.customerId().trim(), fee.inboundCode().trim()) < 1)
throw bad("入仓码不属于关联入仓计划和客户");
if ("OVERSEAS_CONTAINER".equals(fee.businessType())) { if ("OVERSEAS_CONTAINER".equals(fee.businessType())) {
requireOwnedContainer(fee); requireOwnedContainer(fee);
return; return;
@@ -45,28 +47,21 @@ public class OverseasFeeBusinessGuard {
requireActiveOverseasWarehouse(fee.warehouseId()); requireActiveOverseasWarehouse(fee.warehouseId());
return; return;
} }
Table table = switch (fee.businessType()) { int count;
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no"); try {
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no"); count = repository.owned(fee.businessType(), fee.businessId(), fee.customerId().trim(), fee.businessNo().trim());
case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no"); } catch (IllegalArgumentException exception) {
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no"); throw bad("业务类型与业务记录ID不匹配");
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no"); }
case "LASTLEG_ORDER" -> new Table("ll_order", "order_no"); if (count != 1) throw bad("费用关联业务不存在或不属于该客户");
case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no"); if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())
case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no"); && repository.inbound(fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim()) < 1) {
default -> throw bad("业务类型与业务记录ID不匹配"); throw bad("入仓码不属于该客户和海外入仓计划");
};
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name
+ " WHERE id=? AND customer_id=? AND " + table.number + "=?",
Integer.class, fee.businessId(), fee.customerId().trim(), fee.businessNo().trim());
if (count == null || count != 1) throw bad("费用关联业务不存在或不属于该客户");
if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())) {
Integer inbound = jdbc.queryForObject("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=? AND customer_id=? AND inbound_code=?",
Integer.class, fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim());
if (inbound == null || inbound < 1) throw bad("入仓码不属于该客户和海外入仓计划");
} }
} }
/** 真实柜费用既核对柜号,也核对柜内客户,不能跨客户挂账。 */
private void requireOwnedContainer(SaveRequest fee) { private void requireOwnedContainer(SaveRequest fee) {
if (fee.businessId() == null || fee.containerBookingId() == null if (fee.businessId() == null || fee.containerBookingId() == null
|| !fee.businessId().equals(fee.containerBookingId())) || !fee.businessId().equals(fee.containerBookingId()))
@@ -75,24 +70,15 @@ public class OverseasFeeBusinessGuard {
throw bad("海外货柜费用的业务编号必须与真实集装箱号一致"); throw bad("海外货柜费用的业务编号必须与真实集装箱号一致");
String bookingId = String.valueOf(fee.containerBookingId()); String bookingId = String.valueOf(fee.containerBookingId());
Integer total = jdbc.queryForObject( if (repository.container(bookingId) < 1) throw bad("费用关联的真实货柜不存在");
"SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=?", if (repository.inconsistentContainer(bookingId, fee.containerNo().trim()) > 0)
Integer.class, bookingId);
if (total == null || total < 1) throw bad("费用关联的真实货柜不存在");
Integer inconsistent = jdbc.queryForObject(
"SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? "
+ "AND (container_no IS NULL OR TRIM(container_no)='' OR BINARY TRIM(container_no)<>BINARY ?)",
Integer.class, bookingId, fee.containerNo().trim());
if (inconsistent == null || inconsistent > 0)
throw bad("真实货柜的集装箱号缺失或不一致"); throw bad("真实货柜的集装箱号缺失或不一致");
if (repository.containerCustomer(bookingId, fee.customerId().trim()) < 1)
Integer owned = jdbc.queryForObject( throw bad("该客户不属于费用关联的真实货柜");
"SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? AND customer_id=?",
Integer.class, bookingId, fee.customerId().trim());
if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜");
} }
/** 无业务实体的历史仓服务只允许有效共享海外仓。 */
private void requireActiveOverseasWarehouse(long warehouseId) { private void requireActiveOverseasWarehouse(long warehouseId) {
try { try {
var response = warehouses.activeWarehouse(warehouseId, internalToken); var response = warehouses.activeWarehouse(warehouseId, internalToken);
@@ -108,9 +94,10 @@ public class OverseasFeeBusinessGuard {
} }
} }
/** 判断业务可选字段是否为空。 */
private static boolean blank(String value) { return value == null || value.isBlank(); } private static boolean blank(String value) { return value == null || value.isBlank(); }
/** 返回业务参数错误。 */
private static ResponseStatusException bad(String message) { private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
} }
private record Table(String name, String number) { }
} }
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.client.OverseasFeeCustomerClient;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
/** 费用查询按客户 ID 展示当前主档名称,历史快照只在查名不可用时兜底,不回写财务历史。 */
@Component
public class OverseasFeeCustomerNames {
private static final Logger log = LoggerFactory.getLogger(OverseasFeeCustomerNames.class);
private final OverseasFeeCustomerClient client;
private final String token;
/** 使用现有 IAM 地址和内部服务身份查询客户。 */
public OverseasFeeCustomerNames(OverseasFeeCustomerClient client,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.client = client;
this.token = token;
}
/** 同一客户只查询一次主档,防止列表混用旧名称而与详情、财务不一致。 */
public Map<String, String> resolve(Collection<ContainerCustomerOption> customers) {
Map<String, String> names = new LinkedHashMap<>();
var ids = new LinkedHashSet<String>();
for (var customer : customers) {
if (customer.customerId() == null || customer.customerId().isBlank()) continue;
ids.add(customer.customerId());
if (customer.customerName() != null && !customer.customerName().isBlank())
names.putIfAbsent(customer.customerId(), customer.customerName());
}
if (token == null || token.isBlank()) return names;
for (String id : ids) {
long customerId;
try { customerId = Long.parseLong(id); }
catch (NumberFormatException invalid) { continue; }
if (customerId <= 0) continue;
try {
var response = client.customers(token, 1, 1, customerId);
if (response == null || !response.successful() || response.data() == null) {
log.warn("海外费用客户名称查询暂不可用");
break;
}
response.data().records().stream()
.filter(customer -> Long.valueOf(customerId).equals(customer.id()))
.map(OverseasFeeCustomerClient.Customer::tenantName)
.filter(name -> name != null && !name.isBlank()).findFirst()
.ifPresent(name -> names.put(id, name.trim()));
} catch (RuntimeException unavailable) {
// 查名失败不能隐藏费用或影响资金状态;停止本轮远端调用,后续查询可重试。
log.warn("海外费用客户名称查询失败,保留费用原快照", unavailable);
break;
}
}
return names;
}
}
@@ -0,0 +1,217 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.*;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.RetryDeliveryRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
import com.baiyue.lastleg.financefee.repository.OverseasFeeDeliveryRepository;
import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.nio.charset.StandardCharsets;
/** 费用明细只重投原事件;沿用事件身份、摘要和运维审计,不能重新生成收费。 */
@Service
@RequiredArgsConstructor
public class OverseasFeeDeliveryService {
private final OverseasFeeDeliveryRepository repository;
private final OverseasManualFeeRepository fees;
private final ObjectMapper json;
/** 批量读取当前版本的实际投递结果,不把已写入队列显示为财务已接收。 */
public void describe(List<OverseasFeeSource> rows) {
if (rows.isEmpty()) return;
var states = fees.funding(rows.stream().map(OverseasFeeSource::getSourceFeeId).toList()).stream()
.collect(Collectors.toMap(OverseasFeeFunding::getSourceFeeId, Function.identity()));
Map<String, OverseasFeeSource> keys = new HashMap<>();
for (var fee : rows) {
if (fee.getAllowedActions() != null) fee.setAllowedActions(fee.getAllowedActions().stream()
.filter(action -> !Set.of("RETRY_PUBLISH", "RETRY_FUNDING_DELIVERY").contains(action)).toList());
fee.setDeliveryStatus(null); fee.setDeliveryKind(null); fee.setDeliveryEventId(null); fee.setDeliveryErrorMessage(null);
if (eligible(fee, states.get(fee.getSourceFeeId()))) {
String key = eventKey(fee, states.get(fee.getSourceFeeId()));
if (key != null) keys.put(key, fee);
}
}
if (keys.isEmpty()) return;
for (var row : repository.events(keys.keySet())) {
var fee = keys.get(row.getEventKey());
if (fee == null || !Objects.equals(fee.getCustomerId(), row.getCustomerId())) continue;
String kind = EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(row.getEventType()) ? "FEE" : "FUNDING";
fee.setDeliveryKind(kind); fee.setDeliveryStatus(row.getStatus()); fee.setDeliveryErrorMessage(row.getLastError());
try { fee.setDeliveryEventId(envelope(row, fee, states.get(fee.getSourceFeeId())).eventId()); }
catch (RuntimeException invalid) { continue; } // 损坏或串单事件不开放重试。
if (retryable(row)) {
var actions = new ArrayList<>(fee.getAllowedActions() == null ? List.<String>of() : fee.getAllowedActions());
actions.add("FEE".equals(kind) ? "RETRY_PUBLISH" : "RETRY_FUNDING_DELIVERY");
fee.setAllowedActions(List.copyOf(actions));
}
}
}
/** 锁定当前费用后校验版本及原事件;并发请求由事件行锁和唯一审计请求号防重。 */
@Transactional
public void retry(long id, String operator, RetryDeliveryRequest request) {
var fee = fees.lockFee(id);
if (fee == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在");
var funding = fees.lockFunding(id);
if (!eligible(fee, funding) || !Objects.equals(fee.getSourceVersion(), request.expectedVersion())) throw conflict("费用版本或状态已变化,请刷新");
String key = eventKey(fee, funding);
var row = key == null ? null : repository.lock(key);
if (row == null) throw conflict("当前费用没有可重试的投递记录");
var event = envelope(row, fee, funding);
if (!Objects.equals(event.eventId(), request.eventId())) throw conflict("投递记录已变化,请刷新");
try { if (!UUID.fromString(request.idempotencyKey()).toString().equalsIgnoreCase(request.idempotencyKey())) throw new IllegalArgumentException(); }
catch (RuntimeException invalid) { throw conflict("重试请求编号无效"); }
var prior = repository.request(request.idempotencyKey());
if (prior != null) {
if (!Objects.equals(prior.getEntryId(), String.valueOf(row.getId())) || !Objects.equals(prior.getEventId(), event.eventId())
|| !Objects.equals(prior.getPayloadHash(), event.payloadHash()) || !Objects.equals(prior.getOperatorName(), operator)) throw conflict("操作请求编号已用于不同重试参数");
return;
}
if (!retryable(row)) throw conflict("仅允许重试失败事件,正在发送或已成功的不可重试");
var now = LocalDateTime.now(ZoneOffset.UTC);
if (repository.requeue(row.getId(), now) != 1) throw conflict("投递状态已变化,请刷新");
repository.insert(audit(row, event, request.idempotencyKey(), operator, "重试推送", now));
}
/** 人工资金失败重试与新指令在同一业务事务记录,不因回执延迟漏记操作。 */
public void recordFundingRetry(OverseasFeeSource fee, OverseasFeeFunding funding, String operator, String key) {
String operation = operation(fee, funding);
String eventKey = "WALLET_COMMAND:" + fee.getCustomerId() + ":" + walletType(fee) + ":" + funding.getWalletBusinessNo() + ':' + operation + ':' + funding.getCommandVersion();
var row = repository.event(eventKey);
if (row == null) throw conflict("资金重试指令缺少原始记录");
var event = envelope(row, fee, funding);
String requestId = UUID.nameUUIDFromBytes((fee.getSourceFeeId() + ":" + key + ":" + operator).getBytes(StandardCharsets.UTF_8)).toString();
String action = switch (operation) { case "FREEZE" -> "重试锁定"; case "RELEASE" -> "重试释放"; default -> "重试扣费"; };
repository.insert(audit(row, event, requestId, operator, action, LocalDateTime.now(ZoneOffset.UTC)));
}
public record RetryRecord(String requestId, LocalDateTime requestedAt, String operatorName, String action, String result, String errorMessage) { }
/** 每次重试独立回看当次投递周期和资金结果,不把提交请求当成成功。 */
public List<RetryRecord> history(long id) {
var fee = fees.current(id);
if (fee == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在");
var rows = repository.history(fee.getCustomerId(), fee.getSourceType(), id);
Map<String, LocalDateTime> until = new HashMap<>();
List<RetryRecord> result = new ArrayList<>();
for (var row : rows) {
// 余额费用不再要求费用推送,旧推送审计保留但不混入当前扣费重试记录。
if ("RECEIVABLE".equals(fee.getDirection()) && fee.getWalletSettlement() != null
&& EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(row.getEventType())) continue;
var upper = until.put(row.getEventId(), row.getRequestedAt());
String outcome = row.getAttemptOutcome();
String status = "SUBMITTED", error = null;
if (Set.of("APPLIED", "DUPLICATE", "STALE").contains(Objects.toString(outcome, ""))) status = "SUCCESS";
// RETRY表示本次投递已失败,只是系统还会自动重试,不能一直显示已提交。
else if (Set.of("RETRY", "DEAD", "LEASE_EXPIRED").contains(Objects.toString(outcome, ""))
|| upper == null && ("DEAD".equals(row.getCurrentStatus())
|| Set.of("PENDING", "PROCESSING").contains(Objects.toString(row.getCurrentStatus(), ""))
&& row.getCurrentError() != null && !row.getCurrentError().isBlank())) { status = "FAILED"; error = row.getCurrentError(); }
if (EventTypes.WALLET_COMMAND_REQUESTED.equals(row.getEventType())) {
status = "FAILED".equals(status) ? status : "SUBMITTED";
if (row.getWalletResult() != null) try {
var envelope = json.readValue(row.getWalletResult(), EventEnvelope.class);
var completed = json.treeToValue(envelope.payload(), WalletCommandCompletedV1.class);
if (Objects.equals(completed.commandEventId(), row.getEventId()) && Objects.equals(String.valueOf(completed.customerId()), fee.getCustomerId())) {
status = "SUCCEEDED".equals(completed.result()) ? "SUCCESS" : "FAILED"; error = completed.errorMessage();
}
} catch (Exception invalid) { status = "FAILED"; error = "重试结果读取失败"; }
}
result.add(new RetryRecord(row.getRequestId(), row.getRequestedAt(), row.getOperatorName(), row.getReason(), status, error));
}
return result;
}
/** 审计沿用原事件身份和摘要,保存实际操作人及失败快照。 */
private OverseasFeeRetryAudit audit(LastlegEventOutbox row, EventEnvelope event, String key, String operator, String reason, LocalDateTime time) {
var value = new OverseasFeeRetryAudit(); value.setRequestId(key); value.setProducerTable("ll_event_outbox");
value.setEntryId(String.valueOf(row.getId())); value.setEventId(event.eventId()); value.setEventType(event.eventType());
value.setPayloadHash(event.payloadHash()); value.setOperatorName(operator); value.setReason(reason);
value.setPreviousFailures(row.getRetryCount() == null ? 0 : row.getRetryCount()); value.setPreviousError(row.getLastError()); value.setRequestedAt(time);
return value;
}
/** 终结收费仍须完成原冻结/释放指令,重投不得重新打开扣费或费用发布。 */
private boolean eligible(OverseasFeeSource fee, OverseasFeeFunding funding) {
if (funding != null && "WALLET".equals(funding.getCollectionMode())
&& ("VOIDED".equals(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus()))
&& Set.of("FREEZE_PENDING", "RELEASE_PENDING").contains(Objects.toString(funding.getWalletStatus(), ""))) return true;
return "ACTIVE".equals(fee.getSourceStatus()) && !"REJECTED".equals(fee.getReviewStatus())
&& !Set.of("PENDING_FINANCE", "FINANCE_ATTENTION").contains(Objects.toString(fee.getVoidStatus(), ""));
}
/** 已失败并等待重试时可立即重推;正在发送或已成功的事件不能抢占。 */
private boolean retryable(LastlegEventOutbox row) {
return "DEAD".equals(row.getStatus()) || "PENDING".equals(row.getStatus())
&& row.getLastError() != null && !row.getLastError().isBlank();
}
/** 来源、版本、动作共同确定唯一事件,不按费用项目名称去重。 */
private String eventKey(OverseasFeeSource fee, OverseasFeeFunding funding) {
// 实扣成功即完成,不再关联费用推送及其失败状态。
if ("RECEIVABLE".equals(fee.getDirection()) && (fee.getWalletSettlement() != null
|| funding != null && "WALLET".equals(funding.getCollectionMode()) && "SETTLED".equals(funding.getWalletStatus()))) return null;
if ("APPROVED".equals(fee.getReviewStatus()) && "PUBLISHED".equals(fee.getPublicationStatus()))
return EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:" + fee.getSourceType() + ':' + fee.getSourceFeeId()
+ ':' + fee.getSourceVersion() + (fee.getSourceVersion() == 1 ? ":PUBLISHED" : ":ADJUSTED");
if (funding == null || !"WALLET".equals(funding.getCollectionMode()) || funding.getWalletStatus() == null
|| !funding.getWalletStatus().endsWith("_PENDING")) return null;
String operation = operation(fee, funding);
return "WALLET_COMMAND:" + fee.getCustomerId() + ":" + walletType(fee) + ":" + funding.getWalletBusinessNo()
+ ':' + operation + ':' + funding.getCommandVersion();
}
/** 重试前核对原载荷哈希及业务归属,保留消费端已处理事件的唯一性。 */
private EventEnvelope envelope(LastlegEventOutbox row, OverseasFeeSource fee, OverseasFeeFunding funding) {
try {
var event = json.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
if (!Objects.equals(row.getCustomerId(), fee.getCustomerId()) || !Objects.equals(event.tenantId(), fee.getCustomerId())
|| !Objects.equals(event.correlationId(), row.getEventKey()) || !Objects.equals(event.eventType(), row.getEventType())
|| !EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash()))
throw conflict("原事件摘要或客户不一致");
if (EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(event.eventType())) {
var value = json.treeToValue(event.payload(), BusinessFeeSourceChangedV1.class);
if (!Objects.equals(value.sourceFeeId(), String.valueOf(fee.getSourceFeeId()))
|| !Objects.equals(value.sourceVersion(), fee.getSourceVersion()) || !Objects.equals(value.customerId(), fee.getCustomerId())
|| !Objects.equals(value.sourceType(), fee.getSourceType()) || !"LASTLEG".equals(value.sourceSystem())
|| !Objects.equals(event.aggregateId(), value.sourceFeeId()) || !Objects.equals(event.aggregateType(), value.sourceType())
|| event.aggregateVersion() != value.sourceVersion())
throw conflict("推送记录与费用不一致");
} else if (EventTypes.WALLET_COMMAND_REQUESTED.equals(event.eventType())) {
var value = json.treeToValue(event.payload(), WalletCommandRequestedV1.class);
if (funding == null || !Objects.equals(event.eventId(), funding.getCommandEventId())
|| !Objects.equals(value.businessNo(), funding.getWalletBusinessNo()) || !walletType(fee).equals(value.businessType())
|| !Objects.equals(String.valueOf(value.customerId()), fee.getCustomerId())
|| !Objects.equals(event.aggregateId(), funding.getWalletBusinessNo()) || !walletType(fee).equals(event.aggregateType())
|| event.aggregateVersion() != funding.getCommandVersion()
|| !Objects.equals(value.operation(), operation(fee, funding))) throw conflict("资金记录与费用不一致");
} else throw conflict("投递事件类型不属于费用");
return event;
} catch (ResponseStatusException invalid) { throw invalid; }
catch (Exception invalid) { throw conflict("原投递记录无效,不能重试"); }
}
/** 仓租沿原出库冻结身份,人工费用沿各自资金身份。 */
private String walletType(OverseasFeeSource fee) { return StorageRentFeeWorkflow.isRent(fee) ? "STORAGE_RENT" : "OVERSEAS_MANUAL_FEE"; }
/** 仓租扣费使用审核后的实额结算指令。 */
private String operation(OverseasFeeSource fee, OverseasFeeFunding funding) {
String operation = funding.getWalletStatus().replace("_PENDING", "");
return StorageRentFeeWorkflow.isRent(fee) && "SETTLE".equals(operation) ? "SETTLE_ACTUAL" : operation;
}
/** 统一返回状态冲突,禁止通过重试重建费用。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -26,7 +26,9 @@ public class OverseasFeeOutboxService {
@Resource private LastlegEventOutboxMapper mapper; @Resource private LastlegEventOutboxMapper mapper;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
/** 仅推送账单费用;已实扣的海外应收保留本地明细,不再重复入财务结算池。 */
public void publish(OverseasFeeSource source, String action) { public void publish(OverseasFeeSource source, String action) {
if ("RECEIVABLE".equals(source.getDirection()) && source.getWalletSettlement() != null) return;
BusinessFeeSourceChangedV1 unsigned = payload(source, null, null); BusinessFeeSourceChangedV1 unsigned = payload(source, null, null);
String sourceHash = EventPayloadHash.sha256( String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
@@ -98,10 +98,11 @@ public class OverseasFeeSourceService {
return value; return value;
} }
/** 自动确认仅推进新草稿,已驳回的原费用不得重新审核发布。 */
@Transactional @Transactional
public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) { public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) {
OverseasFeeSource value = create(operatorId, key, request); OverseasFeeSource value = create(operatorId, key, request);
if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) { if ("DRAFT".equals(value.getReviewStatus())) {
value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit")); value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit"));
} }
if ("PENDING".equals(value.getReviewStatus())) { if ("PENDING".equals(value.getReviewStatus())) {
@@ -114,10 +115,48 @@ public class OverseasFeeSourceService {
return value; return value;
} }
/** 旧换标资金收尾复用既有来源,已发布账单不再生成第二笔应收。 */
@Transactional
public OverseasFeeSource completeLegacyRelabelSettlement(String operator, String key, SaveRequest request,
com.baiyue.common.event.WalletSettlementReference settlement) {
if (!"RELABEL_ORDER".equals(request.businessType()) || !"RELABEL_SERVICE".equals(request.feeItemCode())
|| !"RECEIVABLE".equals(request.direction()) || !"CNY".equals(request.currency())
|| settlement == null || text(settlement.commandEventId()) == null || text(settlement.holdId()) == null
|| text(settlement.ledgerId()) == null) throw bad("历史换标扣款引用不完整");
var prior = listForBusiness(request.customerId(), "RELABEL_ORDER", request.businessId()).stream()
.filter(f -> "RELABEL_SERVICE".equals(f.getFeeItemCode()) && "RECEIVABLE".equals(f.getDirection())
&& "ACTIVE".equals(f.getSourceStatus())).findFirst();
if (prior.isPresent()) {
var value = mapper.lockCurrent(prior.get().getSourceFeeId());
if (value == null || value.getAmount() == null || value.getAmount().compareTo(request.amount()) != 0)
throw conflict("历史换标应收与扣款金额不一致,请核对");
if (value.getWalletSettlement() != null && !value.getWalletSettlement().equals(settlement))
throw conflict("历史换标已关联其他扣款,请核对");
value.setWalletSettlement(settlement); value.setUpdatedBy(operator); value.setUpdatedAt(now()); mapper.updateById(value);
return value;
}
return publishSettled(operator,key,request,currencyConverter.convert(request.currency(),request.amount(),request.businessDate()),settlement);
}
private record SettledRequest(SaveRequest fee, OverseasFeeCurrencyConverter.Conversion conversion, private record SettledRequest(SaveRequest fee, OverseasFeeCurrencyConverter.Conversion conversion,
com.baiyue.common.event.WalletSettlementReference settlement) { } com.baiyue.common.event.WalletSettlementReference settlement) { }
/** Internal business entry: money has already succeeded and its confirmed FX must not be recalculated. */ /** 实际出库先登记待审核仓租,沿用冻结汇率,不发账单或扣款。 */
@Transactional
public OverseasFeeSource createPendingStorageRent(String key, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion conversion) {
var prior = replay("STORAGE_RENT", "RENT_CREATE", key, request);
if (prior != null) return current(prior.getSourceFeeId());
if (!"FEE_TYPE_077".equals(request.feeItemCode()) || !"RECEIVABLE".equals(request.direction()))
throw bad("仓租费用项目或方向无效");
validate(request); businessGuard.requireOwned(request);
var fee = build(IdWorker.getId(), IdWorker.getId(), 1, "STORAGE_RENT", request, conversion);
fee.setReviewStatus("PENDING"); mapper.insert(fee);
command("STORAGE_RENT", "RENT_CREATE", key, request, fee.getSourceFeeId(), 1);
return fee;
}
/** 实扣成功后登记海外费用,沿用已确认汇率和资金流水,不再推送账单结算。 */
@Transactional @Transactional
public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request, public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion conversion, OverseasFeeCurrencyConverter.Conversion conversion,
@@ -139,23 +178,24 @@ public class OverseasFeeSourceService {
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion); OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion);
value.setWalletSettlement(settlement); value.setWalletSettlement(settlement);
value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED"); value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED");
value.setReviewerId(operatorId); value.setReviewedAt(now()); value.setReviewerId(operatorId); value.setReviewerName(operatorId); value.setReviewedAt(now());
value.setReviewReason("已核定业务费用扣款成功后发布"); value.setReviewReason("业务费用已扣款");
mapper.insert(value); mapper.insert(value);
outbox.publish(value, "PUBLISHED");
command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1); command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1);
return value; return value;
} }
/** 旧提交入口同样以驳回为终态,保留原审核记录。 */
@Transactional @Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) { public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId); OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
if (replay != null) return current(replay.getSourceFeeId()); if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId); OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType()); requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus())) // 驳回代表本条费用终结,录错后必须新建费用,不能覆盖原审核记录。
throw conflict("只有草稿或已驳回费用可以提交"); if (!"DRAFT".equals(value.getReviewStatus()))
value.setReviewStatus("PENDING"); value.setReviewReason(null); throw conflict("已驳回费用不能重新提交,请重新新增费用");
value.setReviewStatus("PENDING"); value.setReviewReason(null); value.setReviewerId(null); value.setReviewerName(null); value.setReviewedAt(null);
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
command(operatorId, "SUBMIT", key, sourceFeeId, sourceFeeId, value.getSourceVersion()); command(operatorId, "SUBMIT", key, sourceFeeId, sourceFeeId, value.getSourceVersion());
return value; return value;
@@ -163,16 +203,26 @@ public class OverseasFeeSourceService {
@Transactional @Transactional
public OverseasFeeSource review(long sourceFeeId, String operatorId, ReviewRequest request) { public OverseasFeeSource review(long sourceFeeId, String operatorId, ReviewRequest request) {
return review(sourceFeeId, operatorId, operatorId, request);
}
/** 兼容已有内部调用,并保存审核人展示名称快照。 */
@Transactional
public OverseasFeeSource review(long sourceFeeId, String operatorId, String reviewerName, ReviewRequest request) {
OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request); OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId()); if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId); OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType()); requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())) if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
throw conflict("只有待审核的有效费用可以审核"); throw conflict("只有待审核的有效费用可以审核");
if (request.expectedVersion() != null && !Objects.equals(value.getSourceVersion(), request.expectedVersion()))
throw conflict("费用版本已变化,请刷新后重新审核");
boolean approved = "APPROVE".equals(request.decision()); boolean approved = "APPROVE".equals(request.decision());
if (!approved && text(request.reason()) == null) throw bad("请填写驳回原因");
value.setReviewStatus(approved ? "APPROVED" : "REJECTED"); value.setReviewStatus(approved ? "APPROVED" : "REJECTED");
value.setPublicationStatus(approved ? "PUBLISHED" : "UNPUBLISHED"); value.setPublicationStatus(approved ? "PUBLISHED" : "UNPUBLISHED");
value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId); value.setReviewedAt(now()); value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId);
value.setReviewerName(text(reviewerName) == null ? operatorId : text(reviewerName)); value.setReviewedAt(now());
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
if (approved) outbox.publish(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED"); if (approved) outbox.publish(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED");
command(operatorId, "REVIEW", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion()); command(operatorId, "REVIEW", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion());
@@ -228,6 +278,7 @@ public class OverseasFeeSourceService {
return value; return value;
} }
/** 保存费用及业务说明快照,和审核原因分别记录。 */
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request, private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion confirmedConversion) { OverseasFeeCurrencyConverter.Conversion confirmedConversion) {
OverseasFeeSource value = new OverseasFeeSource(); OverseasFeeSource value = new OverseasFeeSource();
@@ -238,6 +289,7 @@ public class OverseasFeeSourceService {
value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setInboundCode(text(request.inboundCode())); value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setInboundCode(text(request.inboundCode()));
value.setContainerId(request.containerId()); value.setContainerBookingId(request.containerBookingId()); value.setContainerNo(text(request.containerNo())); value.setContainerId(request.containerId()); value.setContainerBookingId(request.containerBookingId()); value.setContainerNo(text(request.containerNo()));
value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim()); value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim());
value.setFeeRemark(text(request.feeRemark()));
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName())); value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo())); value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
@@ -255,6 +307,7 @@ public class OverseasFeeSourceService {
return value; return value;
} }
/** 校验业务来源、费用项目与应收应付信息,不允许不同业务类型混挂。 */
private void validate(SaveRequest value) { private void validate(SaveRequest value) {
String expected = switch (value.businessType()) { String expected = switch (value.businessType()) {
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE"; case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
@@ -262,7 +315,7 @@ public class OverseasFeeSourceService {
case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE"; case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE";
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE"; case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE"; case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE"; case "WAREHOUSE_SERVICE", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "DISCARD_ORDER" -> "OVERSEAS_WAREHOUSE_FEE";
case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE"; case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE";
case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE"; case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE";
case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE"; case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE";
@@ -270,6 +323,11 @@ public class OverseasFeeSourceService {
default -> ""; default -> "";
}; };
if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配"); if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配");
// 采购、本土卡车和本土散板都是客户消费类业务:只能形成应收,并由钱包余额/授信扣款。
if (Set.of("OVERSEAS_PROCUREMENT", "PROCUREMENT_FEE", "US_TRUCK", "LOCAL_LOOSEBOARD").contains(value.businessType())
&& !"RECEIVABLE".equals(value.direction())) {
throw bad("采购、本土卡车和本土散板费用只能登记应收");
}
if ("OVERSEAS_CONTAINER".equals(value.businessType()) if ("OVERSEAS_CONTAINER".equals(value.businessType())
&& !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim())) && !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim()))
throw bad("海外货柜费用项目不受支持"); throw bad("海外货柜费用项目不受支持");
@@ -282,9 +340,11 @@ public class OverseasFeeSourceService {
boolean payable = "PAYABLE".equals(value.direction()); boolean payable = "PAYABLE".equals(value.direction());
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if (!payable) { if (!payable) {
if (value.supplierId() != null || text(value.supplierName()) != null || text(value.settlementMode()) != null // 应收结算选择不代表供应商已付事实,两类信息仍严格隔离。
if (value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode())
|| value.supplierId() != null || text(value.supplierName()) != null || text(value.supplierCategory()) != null
|| value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files) || value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files)
throw bad("应收费用不得携带供应商或付款信息"); throw bad("应收费用只能选择推送结算或余额扣费,不得携带付款信息");
return; return;
} }
if (value.supplierId() == null || text(value.supplierName()) == null || !Set.of("ON_ACCOUNT", "DIRECT_PAID").contains(value.settlementMode())) if (value.supplierId() == null || text(value.supplierName()) == null || !Set.of("ON_ACCOUNT", "DIRECT_PAID").contains(value.settlementMode()))
@@ -0,0 +1,528 @@
package com.baiyue.lastleg.financefee.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.common.event.WalletSettlementReference;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.*;
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository;
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.wallet.WalletResultHandler;
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 人工收费先锁款、再审核;只有 Finance 确认实扣后才发布已收费用。 */
@Service
@RequiredArgsConstructor
public class OverseasManualFeeService implements WalletResultHandler {
@jakarta.annotation.Resource private StorageRentFeeWorkflow rentReview;
private static final String WALLET_BUSINESS = "OVERSEAS_MANUAL_FEE";
private final OverseasFeeSourceService sources;
private final OverseasManualFeeRepository repository;
private final OverseasFeeBusinessRepository businesses;
private final FeeCatalogGateway catalog;
private final WalletCommandOutboxService wallet;
private final OverseasFeeOutboxService outbox;
private final ObjectMapper json;
private final OverseasFeeCancellation cancellation;
private final OverseasFeeCustomerNames customerNames;
private final OverseasFeeDeliveryService delivery;
/** 分页筛选所有当前费用,包含自动仓租和资金处理进度。 */
public PageResult<OverseasFeeSource> page(int page, int size, String customerId, String businessType,
String businessNo, String feeItemCode, String reviewStatus, String walletStatus, String direction, String processingStatus) {
return page(page,size,customerId,businessType,businessNo,feeItemCode,reviewStatus,walletStatus,direction,processingStatus,false);
}
/** 仓租专页按真实出库计租来源过滤,不能仅凭费用项目名称判断自动仓租。 */
public PageResult<OverseasFeeSource> page(int page, int size, String customerId, String businessType,
String businessNo, String feeItemCode, String reviewStatus, String walletStatus, String direction, String processingStatus, boolean storageRentOnly) {
if (page < 1 || size < 1 || size > 200) throw bad("分页参数无效");
Map<String, Object> query = new HashMap<>();
query.put("customerId", text(customerId)); query.put("businessType", text(businessType));
query.put("businessNo", text(businessNo)); query.put("feeItemCode", text(feeItemCode));
query.put("reviewStatus", text(reviewStatus)); query.put("walletStatus", text(walletStatus));
if (direction != null && !List.of("RECEIVABLE", "PAYABLE").contains(direction)) throw bad("收支方向无效");
query.put("direction", direction == null ? "RECEIVABLE" : direction);
if (text(processingStatus) != null && !Set.of("WAITING", "PROCESSING", "COMPLETED", "FAILED", "CLOSED").contains(processingStatus)) throw bad("费用状态无效");
query.put("processingStatus", text(processingStatus));
query.put("storageRentOnly",storageRentOnly);
query.put("offset", ((long) page - 1) * size); query.put("size", size);
long total = repository.count(query);
return new PageResult<>(describe(repository.page(query)), page, size, total, (total + size - 1) / size);
}
/** 费用详情与列表使用一致的资金状态和可操作性。 */
public OverseasFeeSource detail(long id) { return describe(List.of(required(repository.current(id)))).get(0); }
/** 换标申请作为收费主业务后,履约详情仍需读取同客户的申请费用。 */
public List<OverseasFeeSource> relabelFeesForOrder(String customerId, long orderId) {
var business = businesses.relabelForOrder(orderId, customerId);
return business == null ? List.of() : sources.listForBusiness(customerId, "RELABEL_ORDER", business.businessId());
}
/** 批量补充资金状态和业务分类;客户显示名称以主档为准,不回写原收费凭据。 */
public List<OverseasFeeSource> describe(List<OverseasFeeSource> fees) {
cancellation.describe(fees);
var funding = repository.funding(fees.stream().map(OverseasFeeSource::getSourceFeeId).toList())
.stream().collect(Collectors.toMap(OverseasFeeFunding::getSourceFeeId, Function.identity()));
for (var fee : fees) applyDescription(fee, funding.get(fee.getSourceFeeId()));
var names = customerNames.resolve(fees.stream()
.map(fee -> new ContainerCustomerOption(fee.getCustomerId(), fee.getCustomerName())).toList());
var relabelIds = new HashSet<>(businesses.relabelFeeIds(fees.stream()
.filter(fee -> "LASTLEG_ORDER".equals(fee.getBusinessType()))
.map(OverseasFeeSource::getSourceFeeId).distinct().toList()));
for (var fee : fees) {
fee.setDisplayBusinessType(relabelIds.contains(fee.getSourceFeeId()) ? "RELABEL_ORDER" : fee.getBusinessType());
fee.setCustomerName(names.getOrDefault(fee.getCustomerId(), fee.getCustomerName()));
}
if(rentReview!=null)rentReview.describe(fees);
return fees;
}
/** 客户范围来自真实业务,名称与费用详情统一取主档,不增加客户管理权限。 */
public List<ContainerCustomerOption> customers() {
var customers = businesses.customers();
var names = customerNames.resolve(customers);
return customers.stream().map(customer -> new ContainerCustomerOption(customer.customerId(),
names.getOrDefault(customer.customerId(), customer.customerName()))).toList();
}
/** 候选使用真实服务业务单和当前客户名称,不允许以车辆车次合并客户费用。 */
public List<BusinessOption> businessOptions(String type, String customerId, String keyword) {
if (!Set.of("LASTLEG_ORDER", "RELABEL_ORDER", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "US_TRUCK", "LOCAL_LOOSEBOARD", "DISCARD_ORDER", "OVERSEAS_INBOUND", "DELIVERY_ORDER").contains(type))
throw bad("请选择实际服务业务单");
var options = businesses.options(type, text(customerId), text(keyword));
var names = customerNames.resolve(options.stream()
.map(option -> new ContainerCustomerOption(option.customerId(), option.customerName())).toList());
return options.stream().map(option -> new BusinessOption(option.businessId(), option.businessType(),
option.businessNo(), option.customerId(), names.getOrDefault(option.customerId(), option.customerName()),
option.warehouseId(), option.orderId(), option.orderType())).toList();
}
/** 换标履约只作为关联订单,收费主业务必须使用真实换标申请。 */
private SaveRequest resolveServiceBusiness(SaveRequest request) {
if (!"LASTLEG_ORDER".equals(request.businessType())
|| !"RELABEL_APPLICATION".equals(businesses.orderType(request.businessId(), request.customerId()))) return request;
if (!"OVERSEAS_ORDER_FEE".equals(request.sourceType())
|| businesses.owned("LASTLEG_ORDER", request.businessId(), request.customerId(), request.businessNo()) != 1
|| request.orderId() != null && !request.orderId().equals(request.businessId()))
throw bad("换标费用关联的履约订单不一致");
var business = businesses.relabelForOrder(request.businessId(), request.customerId());
if (business == null) throw bad("换标订单缺少真实换标申请关联,无法登记费用");
return new SaveRequest(request.customerId(), request.customerName(), "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER",
business.businessId(), business.businessNo(), request.businessId(), request.inboundId(), request.inboundCode(),
request.containerId(), request.containerBookingId(), request.containerNo(), request.warehouseId(),
request.feeItemCode(), request.feeItemName(), request.feeRemark(), request.direction(), request.supplierId(), request.supplierName(),
request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(),
request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(), request.currency(),
request.amount(), request.chargeableQuantity(), request.quantityUnit(), request.unitPrice(), request.periodStart(), request.periodEnd());
}
/** 创建立即进入待审核;应收异步冻结资金,头程附加费保留账单结算。 */
@Transactional
public OverseasFeeSource create(String operator, String key, SaveRequest request) {
if (request.businessId() == null) throw bad("请选择本次服务对应的业务订单");
request = resolveServiceBusiness(request);
requireManualItem(request.feeItemCode());
catalog.requireActive(request.feeItemCode(), request.feeItemName(), request.direction());
OverseasFeeSource fee = sources.create(operator, key, request);
fee = required(repository.lockFee(fee.getSourceFeeId()));
OverseasFeeFunding state = repository.lockFunding(fee.getSourceFeeId());
if (state != null) return described(fee, state);
// 幂等重放先返回原结果;新费用在任何冻结前校验重复项目的业务说明。
requireFeeRemark(fee);
requireNoLegacyDuplicate(fee);
state = initialize(fee);
fee.setReviewStatus("PENDING"); fee.setUpdatedAt(now()); repository.updateFee(fee);
if ("WALLET".equals(state.getCollectionMode())) send(fee, state, "FREEZE");
return described(fee, state);
}
/** 同业务同项目的旧自动收费需先核对,人工链自身允许合法分次收费。 */
private void requireNoLegacyDuplicate(OverseasFeeSource fee) {
if (!"RECEIVABLE".equals(fee.getDirection())) return;
boolean relabelProject = Set.of("FEE_TYPE_071","RELABEL_SERVICE").contains(Objects.toString(fee.getFeeItemCode(),""))
|| Set.of("贴标费","换标费").contains(Objects.toString(fee.getFeeItemName(),""));
boolean relabelBusiness = Set.of("RELABEL_ORDER","LASTLEG_ORDER").contains(fee.getBusinessType());
if (relabelProject && relabelBusiness && repository.legacyRelabel(fee.getCustomerId(),fee.getBusinessType(),fee.getBusinessId())>0
|| repository.legacyProject(fee.getCustomerId(),fee.getBusinessType(),fee.getBusinessId(),fee.getFeeItemCode(),fee.getFeeItemName(),fee.getSourceFeeId())>0)
throw conflict("此项目已有旧版收费,请先核对原费用,避免重复收取");
}
/** 兼容历史人工来源时同样建立资金编排,禁止旧接口绕过锁款。 */
private OverseasFeeFunding initialize(OverseasFeeSource fee) {
if (fee.getWalletSettlement() != null) throw conflict("已扣费用不能重新锁款");
OverseasFeeFunding state = new OverseasFeeFunding();
state.setSourceFeeId(fee.getSourceFeeId()); state.setCustomerId(fee.getCustomerId());
boolean bill = bill(fee);
state.setCollectionMode(bill ? "BILL" : "WALLET"); state.setWalletStatus(bill ? "NOT_REQUIRED" : "FREEZE_PENDING");
state.setCommandVersion(0L); state.setCommandHistory(new LinkedHashMap<>());
state.setCreatedAt(now()); state.setUpdatedAt(now());
repository.insertFunding(state);
return state;
}
/** 草稿可提交,驳回费用终结并保留原审核记录。 */
@Transactional
public OverseasFeeSource submit(long id, String operator, String key) {
OverseasFeeSource fee = required(repository.lockFee(id));
requireManual(fee);
OverseasFeeFunding state = repository.lockFunding(id);
if (replayed(operator, "MANUAL_SUBMIT", key, id, id)) return described(fee, state);
boolean legacy = state == null;
if (!"ACTIVE".equals(fee.getSourceStatus()) || fee.getWalletSettlement() != null
|| !("DRAFT".equals(fee.getReviewStatus()) || legacy && "PENDING".equals(fee.getReviewStatus())))
throw conflict("当前费用不能提交");
if (legacy) { requireNoLegacyDuplicate(fee); state = initialize(fee); }
if ("WALLET".equals(state.getCollectionMode())) {
if (!legacy && !Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus())) throw conflict("请先完成资金释放");
send(fee, state, "FREEZE");
}
fee.setReviewStatus("PENDING"); fee.setReviewReason(null); fee.setReviewerId(null); fee.setReviewerName(null); fee.setReviewedAt(null); touch(fee, operator);
record(operator, "MANUAL_SUBMIT", key, id, id, fee.getSourceVersion());
return described(fee, state);
}
/** 审核批准只发扣款指令,异步扣款失败时不得伪装为已收。 */
@Transactional
public OverseasFeeSource review(long id, String operator, ReviewRequest request) {
return review(id, operator, operator, request);
}
/** 保留审核人ID与展示名称,并按审核结果推进锁款或释放。 */
@Transactional
public OverseasFeeSource review(long id, String operator, String reviewerName, ReviewRequest request) {
var current=repository.current(id);
if(current!=null&&StorageRentFeeWorkflow.isRent(current))
return describe(List.of(rentReview.review(id,operator,reviewerName,request))).get(0);
OverseasFeeSource fee = required(repository.lockFee(id));
requireManual(fee);
OverseasFeeFunding state = repository.lockFunding(id);
if (state == null) {
if (!bill(fee)) throw conflict("请先重新提交费用并锁定资金");
requireLegacyCommandOwner(operator, "REVIEW", request.idempotencyKey(), id);
return described(sources.review(id, operator, reviewerName, request), null);
}
if (replayed(operator, "MANUAL_REVIEW", request.idempotencyKey(), id, request)) return described(fee, state);
if (request.expectedVersion() != null && !Objects.equals(fee.getSourceVersion(), request.expectedVersion()))
throw conflict("费用版本已变化,请刷新后重新审核");
if (!"ACTIVE".equals(fee.getSourceStatus()) || !"PENDING".equals(fee.getReviewStatus())) throw conflict("只有待审核费用可以审核");
if (!Set.of("APPROVE", "REJECT").contains(request.decision())) throw bad("审核操作无效");
boolean approved = "APPROVE".equals(request.decision());
if (!approved && text(request.reason()) == null) throw bad("请填写驳回原因");
boolean walletCollection = "WALLET".equals(state.getCollectionMode());
if (approved && walletCollection && !"FROZEN".equals(state.getWalletStatus())) throw conflict("费用资金尚未锁定,暂不能通过审核");
fee.setReviewStatus(approved ? "APPROVED" : "REJECTED");
fee.setPublicationStatus(approved && !walletCollection ? "PUBLISHED" : "UNPUBLISHED");
fee.setReviewerId(operator); fee.setReviewerName(text(reviewerName) == null ? operator : text(reviewerName));
fee.setReviewedAt(now()); fee.setReviewReason(text(request.reason())); touch(fee, operator);
if (approved && walletCollection) send(fee, state, "SETTLE");
else if (approved) outbox.publish(fee, fee.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED");
else if ("FROZEN".equals(state.getWalletStatus())) send(fee, state, "RELEASE");
record(operator, "MANUAL_REVIEW", request.idempotencyKey(), id, request, fee.getSourceVersion());
return described(fee, state);
}
/** 未收取费用可取消;冻结在途时等待匹配回执后自动释放。 */
@Transactional
public OverseasFeeSource cancel(long id, String operator, String key) {
OverseasFeeSource fee = required(repository.lockFee(id));
requireManual(fee);
requireNotRejected(fee);
OverseasFeeFunding state = repository.lockFunding(id);
if (state == null) throw conflict("该来源请通过原业务处理");
if (replayed(operator, "MANUAL_CANCEL", key, id, id)) return described(fee, state);
if (fee.getWalletSettlement() != null || "APPROVED".equals(fee.getReviewStatus()) || "PUBLISHED".equals(fee.getPublicationStatus()))
throw conflict("费用已审核或已收取,不能取消");
fee.setSourceStatus("VOIDED"); touch(fee, operator);
if ("FROZEN".equals(state.getWalletStatus())) send(fee, state, "RELEASE");
record(operator, "MANUAL_CANCEL", key, id, id, fee.getSourceVersion());
return described(fee, state);
}
/** 失败指令显式重试;不改变已经成功的资金事实。 */
@Transactional
public OverseasFeeSource retry(long id, String operator, String key) {
return retry(id, operator, operator, key);
}
/** 重试操作人与新资金指令同事务留痕,重复请求不增加重试记录。 */
@Transactional
public OverseasFeeSource retry(long id, String operator, String operatorName, String key) {
OverseasFeeSource fee = required(repository.lockFee(id));
requireManual(fee);
OverseasFeeFunding state = repository.lockFunding(id);
if (state == null || !"WALLET".equals(state.getCollectionMode())) throw conflict("该费用无需处理余额");
if (replayed(operator, "MANUAL_FUNDING_RETRY", key, id, id)) return described(fee, state);
String operation = switch (state.getWalletStatus()) {
case "FREEZE_FAILED" -> "FREEZE";
case "SETTLE_FAILED" -> "SETTLE";
case "RELEASE_FAILED" -> "RELEASE";
default -> throw conflict("当前资金状态无需重试");
};
if ("FREEZE".equals(operation) && (!"ACTIVE".equals(fee.getSourceStatus()) || !"PENDING".equals(fee.getReviewStatus())))
throw conflict("已驳回或取消的费用不能继续锁款");
send(fee, state, operation);
delivery.recordFundingRetry(fee, state, operatorName, key);
record(operator, "MANUAL_FUNDING_RETRY", key, id, id, fee.getSourceVersion());
return described(fee, state);
}
/** 只接收人工费用独立资金身份,不接管换标和采购原冻结记录。 */
@Override
public boolean supports(String businessType) { return WALLET_BUSINESS.equals(businessType); }
/** 校验客户、业务身份、指令及金额;重复回执不能重复扣款或发布。 */
@Override
@Transactional
public void handle(WalletCommandCompletedV1 result) {
long id;
try { id = Long.parseLong(result.businessNo().split("-", 2)[0]); }
catch (RuntimeException exception) { throw conflict("费用资金业务编号无效"); }
OverseasFeeSource fee = required(repository.lockFee(id));
OverseasFeeFunding state = repository.lockFunding(id);
if (state == null || !supports(result.businessType()) || !fee.getCustomerId().equals(String.valueOf(result.customerId())))
throw conflict("资金结果与费用客户不匹配");
requireCnySnapshot(fee);
if (!"WALLET".equals(state.getCollectionMode()) || result.result() == null || !Set.of("SUCCEEDED", "FAILED").contains(result.result())
|| result.completedAt() == null || result.operation() == null || !Set.of("FREEZE", "SETTLE", "RELEASE").contains(result.operation()))
throw conflict("费用资金结果字段不完整");
String expected = state.getCommandHistory().get(result.commandEventId());
String completed = result.operation() + ':' + hash(result);
if (completed.equals(expected)) return;
if (!Objects.equals(state.getCommandEventId(), result.commandEventId())
|| !Objects.equals(state.getWalletBusinessNo(), result.businessNo())
|| !Objects.equals(state.getWalletStatus(), result.operation() + "_PENDING")
|| !Objects.equals(expected, result.operation())) throw conflict("资金结果与当前费用指令不匹配");
boolean succeeded = "SUCCEEDED".equals(result.result());
if (succeeded && (result.amount() == null || fee.getAmount().compareTo(result.amount()) != 0
|| text(result.walletHoldId()) == null)) throw conflict("资金成功结果金额或冻结引用不匹配");
if (succeeded && !"FREEZE".equals(result.operation()) && !Objects.equals(state.getWalletHoldId(), result.walletHoldId()))
throw conflict("资金结果的冻结引用与原锁款不匹配");
if (succeeded && "SETTLE".equals(result.operation())
&& (!"APPROVED".equals(fee.getReviewStatus()) || !"ACTIVE".equals(fee.getSourceStatus()) || !"UNPUBLISHED".equals(fee.getPublicationStatus())))
throw conflict("当前费用不能接收扣款成功结果");
state.getCommandHistory().put(result.commandEventId(), completed);
String error = text(result.errorMessage());
state.setErrorMessage(succeeded ? null : error == null ? "资金处理失败" : error.substring(0, Math.min(500, error.length())));
if (!succeeded) state.setWalletStatus(result.operation() + "_FAILED");
else {
state.setWalletHoldId(result.walletHoldId());
switch (result.operation()) {
case "FREEZE" -> {
if (!"FROZEN".equals(result.holdStatus())) throw conflict("冻结回执状态无效");
state.setWalletStatus("FROZEN");
if ("VOIDED".equals(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus())) send(fee, state, "RELEASE");
}
case "SETTLE" -> {
if (!"SETTLED".equals(result.holdStatus()) || text(result.walletLedgerId()) == null)
throw conflict("扣款回执缺少已扣流水");
state.setWalletStatus("SETTLED"); state.setWalletLedgerId(result.walletLedgerId());
fee.setWalletSettlement(new WalletSettlementReference(result.commandEventId(), result.walletHoldId(), result.walletLedgerId(), WALLET_BUSINESS, state.getWalletBusinessNo()));
fee.setPublicationStatus("PUBLISHED"); touch(fee, fee.getUpdatedBy());
// 余额及授信实扣即完成,只保留海外费用和资金流水引用,不再进入账单结算。
}
case "RELEASE" -> {
if (!"RELEASED".equals(result.holdStatus())) throw conflict("释放回执状态无效");
state.setWalletStatus("RELEASED");
}
default -> throw conflict("费用资金操作不受支持");
}
}
state.setUpdatedAt(now()); repository.updateFunding(state);
}
/** 已扣款及自动仓租不允许从通用调价入口篡改。 */
@Transactional
public OverseasFeeSource adjust(long id, String operator, AdjustRequest request) {
OverseasFeeSource fee = required(repository.lockFee(id));
requireManual(fee);
requireNotRejected(fee);
OverseasFeeFunding state = repository.lockFunding(id);
if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode()))
throw conflict("余额费用请取消重建;已扣费用请通过退款处理");
if (!bill(fee)) throw conflict("历史海外费用请先完成原账单作废,再重新创建余额费用");
requireManualItem(request.fee().feeItemCode());
catalog.requireActive(request.fee().feeItemCode(), request.fee().feeItemName(), request.fee().direction());
if (!Objects.equals(fee.getCustomerId(), request.fee().customerId()) || !Objects.equals(fee.getBusinessType(), request.fee().businessType())
|| !Objects.equals(fee.getBusinessId(), request.fee().businessId()) || !Objects.equals(fee.getDirection(), request.fee().direction()))
throw conflict("调整不能更换客户、业务订单或收支方向");
requireLegacyCommandOwner(operator, "ADJUST", request.idempotencyKey(), id);
return described(sources.adjust(id, operator, request), state);
}
/** 已扣费用不可作废,账单费用仍等待 Finance 确认作废。 */
@Transactional
public OverseasFeeSource voidSource(long id, String operator, VoidRequest request) {
OverseasFeeSource fee = required(repository.lockFee(id));
requireManual(fee);
requireNotRejected(fee);
OverseasFeeFunding state = repository.lockFunding(id);
if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode()))
throw conflict("已扣费用请通过退款处理,未扣费用请取消");
requireLegacyCommandOwner(operator, "VOID", request.idempotencyKey(), id);
return described(sources.voidSource(id, operator, request), state);
}
/** 资金指令与费用变更同事务写 Outbox,冻结与业务操作互不抢用身份。 */
private void send(OverseasFeeSource fee, OverseasFeeFunding state, String operation) {
requireCnySnapshot(fee);
long version = state.getCommandVersion() + 1;
if ("FREEZE".equals(operation)) state.setWalletBusinessNo(fee.getSourceFeeId() + "-" + version);
String event = wallet.publish(operation, Long.parseLong(fee.getCustomerId()), WALLET_BUSINESS,
state.getWalletBusinessNo(), "FREEZE".equals(operation) ? fee.getAmount() : null,
"海外费用:" + fee.getBusinessNo() + " / " + fee.getFeeItemName(), version);
state.setCommandVersion(version); state.setCommandEventId(event); state.setWalletStatus(operation + "_PENDING");
state.setErrorMessage(null); state.getCommandHistory().put(event, operation);
state.setUpdatedAt(now()); repository.updateFunding(state);
}
/** 可操作性只描述业务资格,最终权限仍由 Controller 校验。 */
private void applyDescription(OverseasFeeSource fee, OverseasFeeFunding state) {
if (state != null) {
fee.setCollectionMode(state.getCollectionMode()); fee.setWalletStatus(state.getWalletStatus()); fee.setWalletErrorMessage(state.getErrorMessage());
} else if (fee.getWalletSettlement() != null) {
fee.setCollectionMode("WALLET"); fee.setWalletStatus("SETTLED");
} else if ("PUBLISHED".equals(fee.getPublicationStatus()) || bill(fee)) {
fee.setCollectionMode("BILL"); fee.setWalletStatus("NOT_REQUIRED");
} else {
fee.setCollectionMode("WALLET");
}
List<String> actions = new ArrayList<>();
if ("ACTIVE".equals(fee.getSourceStatus()) && fee.getWalletSettlement() == null && !procurement(fee)
&& !Set.of("PENDING_FINANCE", "FINANCE_ATTENTION").contains(Objects.toString(fee.getVoidStatus(), ""))) {
if ("PENDING".equals(fee.getReviewStatus())) {
actions.add("REJECT");
if (state == null && bill(fee) || state != null && ("BILL".equals(state.getCollectionMode()) || "FROZEN".equals(state.getWalletStatus()))) actions.add("APPROVE");
if (state == null && !bill(fee)) { actions.remove("REJECT"); actions.add("SUBMIT"); }
}
if (state != null && "FREEZE_FAILED".equals(state.getWalletStatus()) && "PENDING".equals(fee.getReviewStatus())) actions.add("RETRY_FREEZE");
if (state != null && "SETTLE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_SETTLE");
if ("DRAFT".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode())
|| Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus()))) actions.add("SUBMIT");
if ("APPROVED".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode()))) {
if (bill(fee)) actions.add("ADJUST"); actions.add("VOID");
}
}
if (state != null && "RELEASE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_RELEASE");
// 驳回不能重开收费,但资金释放失败仍须向操作端提供补偿入口。
if ("REJECTED".equals(fee.getReviewStatus())) actions.removeIf(action -> !"RETRY_RELEASE".equals(action));
fee.setAllowedActions(List.copyOf(actions));
if(StorageRentFeeWorkflow.isRent(fee)){
// 仓租沿原出库冻结审核,不开放取消重建、通用调价或人工资金身份。
var rentActions=new ArrayList<String>();
if(state!=null&&"ACTIVE".equals(fee.getSourceStatus())&&"PENDING".equals(fee.getReviewStatus())&&"FROZEN".equals(state.getWalletStatus()))
rentActions.addAll(List.of("APPROVE","REJECT","ADJUST_RENT"));
fee.setAllowedActions(List.copyOf(rentActions));
}
}
/** 重复作业或补收必须解释用途,普通首次录费可不填说明。 */
private void requireFeeRemark(OverseasFeeSource fee) {
if (text(fee.getFeeRemark()) == null && repository.hasSameProject(fee))
throw bad("此单已有相同费用项目,请填写费用说明后继续添加");
}
/** 禁止取消或改写驳回记录,录错后应新建费用。 */
private void requireNotRejected(OverseasFeeSource fee) {
if ("REJECTED".equals(fee.getReviewStatus())) throw conflict("已驳回费用不能操作,请重新新增费用");
}
/** 只有实际头程服务订单的附加费走账单,海外新工单始终独立扣款。 */
private boolean bill(OverseasFeeSource fee) {
if ("RECEIVABLE".equals(fee.getDirection()) && "WALLET".equals(fee.getSettlementMode())) return false;
if ("RECEIVABLE".equals(fee.getDirection()) && "BILL".equals(fee.getSettlementMode())) return true;
return !"RECEIVABLE".equals(fee.getDirection()) || "LASTLEG_ORDER".equals(fee.getBusinessType())
&& "FIRST_LEG".equals(businesses.orderType(fee.getBusinessId(), fee.getCustomerId()));
}
/** 采购已有专用锁款与履约结算,不允许通用费用入口重复收取。 */
private void requireManual(OverseasFeeSource fee) {
if(StorageRentFeeWorkflow.isRent(fee))throw conflict("自动仓租请通过仓租审核或调整入口处理");
if (procurement(fee)) throw conflict("采购费用请通过采购履约或财务节点处理");
}
/** 识别采购来源及其工单类型,兼容历史费用数据。 */
private boolean procurement(OverseasFeeSource fee) {
return "OVERSEAS_PROCUREMENT_FEE".equals(fee.getSourceType()) || "OVERSEAS_PROCUREMENT".equals(fee.getBusinessType())
|| "PROCUREMENT_FEE".equals(fee.getBusinessType());
}
/** 仓租只由实际出库自动生成,人工收费和调价不得重复录入。 */
private void requireManualItem(String code) {
if ("FEE_TYPE_077".equals(code)) throw bad("仓租自动计算,请勿手动添加");
}
/** 钱包只收人民币;核对创建时保存的原币和月度汇率快照,不重新取价。 */
private void requireCnySnapshot(OverseasFeeSource fee) {
if (!"RECEIVABLE".equals(fee.getDirection()) || !"CNY".equals(fee.getCurrency()) || fee.getAmount() == null
|| fee.getAmount().signum() <= 0 || fee.getOriginalAmount() == null || fee.getOriginalAmount().signum() <= 0
|| fee.getExchangeRate() == null || fee.getExchangeRate().signum() <= 0 || text(fee.getExchangeRateMonth()) == null
|| fee.getBusinessDate() == null || !fee.getBusinessDate().toString().substring(0, 7).equals(fee.getExchangeRateMonth())
|| fee.getOriginalCurrency() == null || !Set.of("CNY", "USD").contains(fee.getOriginalCurrency())
|| "CNY".equals(fee.getOriginalCurrency()) && fee.getExchangeRate().compareTo(BigDecimal.ONE) != 0
|| fee.getOriginalAmount().multiply(fee.getExchangeRate()).setScale(2, RoundingMode.HALF_UP).compareTo(fee.getAmount()) != 0)
throw conflict("费用人民币结算快照无效");
}
/** 为单条变更结果补充与列表一致的操作资格。 */
private OverseasFeeSource described(OverseasFeeSource fee, OverseasFeeFunding state) { applyDescription(fee, state); return fee; }
/** 兼容旧载荷哈希,同时禁止跨费用复用旧审核、调整或作废操作标识。 */
private void requireLegacyCommandOwner(String operator, String action, String key, long id) {
if (text(key) == null || key.length() > 128) throw bad("操作标识无效");
var existing = repository.command(operator, action, key.trim());
if (existing != null && !Objects.equals(existing.getSourceFeeId(), id))
throw conflict("操作标识已被其他费用使用");
}
/** 核对重复操作的业务ID及载荷,禁止用相同键操作其他费用。 */
private boolean replayed(String operator, String action, String key, long id, Object payload) {
if (text(key) == null || key.length() > 128) throw bad("操作标识无效");
OverseasFeeCommand existing = repository.command(operator, action, key.trim());
if (existing != null && (!Objects.equals(existing.getSourceFeeId(), id) || !existing.getRequestHash().equals(hash(List.of(id, payload)))))
throw conflict("操作标识已被其他请求使用");
return existing != null;
}
/** 本地变更完成后保存操作幂等记录。 */
private void record(String operator, String action, String key, long id, Object payload, int version) {
OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator);
value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(List.of(id, payload)));
value.setSourceFeeId(id); value.setSourceVersion(version); value.setCreatedAt(now()); repository.insertCommand(value);
}
/** 状态变更保留操作人及时间。 */
private void touch(OverseasFeeSource fee, String operator) { fee.setUpdatedBy(operator); fee.setUpdatedAt(now()); repository.updateFee(fee); }
/** 不暴露不存在的费用。 */
private OverseasFeeSource required(OverseasFeeSource value) { if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在"); return value; }
/** 固定载荷哈希用于操作与结果防重。 */
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException("费用操作快照生成失败", exception); }
}
/** 统一使用 UTC 保存业务时刻。 */
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
/** 统一可选文本。 */
private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 返回参数错误。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
/** 返回业务状态冲突。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
import java.util.List;
/** 费用域通过端口委托仓租审核,避免依赖仓租实现。 */
public interface StorageRentFeeWorkflow {
/** 仓租来源标记,写操作仍核对持久化归属。 */
static boolean isRent(OverseasFeeSource fee) { return "STORAGE_RENT".equals(fee.getCreatedBy()) && "FEE_TYPE_077".equals(fee.getFeeItemCode()); }
/** 附加原计租批次和实际出库明细。 */
void describe(List<OverseasFeeSource> rows);
/** 按原冻结审核仓租,批准后申请实际扣款。 */
OverseasFeeSource review(long id,String operator,String name,ReviewRequest request);
}
@@ -27,7 +27,10 @@ public final class OutsourcedBusinessModels {
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */ /** 固定外配入口不接收浏览器作用域或其他协议字段。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
} }
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) { public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加装车照片。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */ /** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
} }
@@ -53,7 +53,7 @@ public class OutsourcedBusinessService {
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */ /** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) { public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
if(request==null)throw bad("照片绑定请求缺失"); if(request==null)throw bad("照片绑定请求缺失");
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE)); return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId(),request.replaceFileId()),SCOPE));
} }
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */ /** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) { public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
@@ -34,7 +34,11 @@ public final class OwnedBusinessModels {
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations, @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
@Size(max=1000) String reason) {} @Size(max=1000) String reason) {}
public record BindPhoto(@NotBlank String recordVersion, public record BindPhoto(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {} @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加装车照片。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
}
public record Gate(boolean allowed,String blockReason) {} public record Gate(boolean allowed,String blockReason) {}
public record Actions(Gate edit,Gate upload) {} public record Actions(Gate edit,Gate upload) {}
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {} public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
@@ -8,7 +8,11 @@ import org.springframework.web.multipart.MultipartFile;
public interface DispatchLoadingPhotoPort { public interface DispatchLoadingPhotoPort {
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT } enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {} record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {} record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加照片,每类最多五张。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
}
record State(String recordVersion,List<Photo> photos) {} record State(String recordVersion,List<Photo> photos) {}
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {} record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
record Download(Photo photo,byte[] content) {} record Download(Photo photo,byte[] content) {}
@@ -77,9 +77,9 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed); var row=repository.lock(seed);
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
var photos=new EnumMap<PhotoSlot,OwnedBusinessModels.Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p); var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); row.setPhotosJson(codec.write(photos));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion()); var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt()); history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
@@ -104,6 +104,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
private void validate(Slot slot,BindPhoto request) { private void validate(Slot slot,BindPhoto request) {
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion()); slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
if(codec.version(request.fileId())<=0)throw bad("文件编号无效"); if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效");
} }
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */ /** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());} private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Photo;
import java.util.ArrayList;
import java.util.List;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 照片按文件追加或精确替换,禁止按类别压缩而丢失其他已保存图片。 */
final class OwnedBusinessPhotoBindings {
/** 工具类仅提供照片引用合并。 */
private OwnedBusinessPhotoBindings() {}
/** 装车每类最多五张;油耗仍为单张,替换目标必须在当前同类引用中。 */
static List<Photo> bind(List<Photo> existing,Photo photo,String replaceFileId) {
var result=new ArrayList<>(existing);
// 油耗沿原单张替换协议,不能因装车改多图而改变其他入口的保存行为。
if(replaceFileId==null && "FUEL".equals(photo.category()))
replaceFileId=existing.stream().filter(p->p.slot()==photo.slot()).map(Photo::fileId).findFirst().orElse(null);
if(replaceFileId!=null) {
int index=-1;
for(int i=0;i<result.size();i++)if(result.get(i).slot()==photo.slot() && result.get(i).fileId().equals(replaceFileId))index=i;
if(index<0)throw conflict("待替换照片已变化,请刷新后核对");
result.remove(index);
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
result.add(index,photo);
} else {
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
int limit="LOADING".equals(photo.category())?5:1;
if(result.stream().filter(p->p.slot()==photo.slot()).count()>=limit)throw conflict("该类照片最多"+limit+"张,请替换已有照片");
result.add(photo);
}
return List.copyOf(result);
}
}
@@ -69,20 +69,20 @@ public class OwnedBusinessRecordService {
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor); var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);} if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
requireVersion(row,version); requireVersion(row,version);
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p); var saved=new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(), verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
row.setPhotosJson(codec.write(List.copyOf(photos.values()))); row.setPhotosJson(codec.write(photos));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope); return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
} }
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */ /** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
private String photoReason(PhotoSlot slot) { private String photoReason(PhotoSlot slot) {
return switch(slot) { return switch(slot) {
case LOADING_SCENE -> "保存装车现场照片"; case LOADING_SCENE -> "保存司机驾驶证照片";
case LOADING_LIST -> "保存装车清单照片"; case LOADING_LIST -> "保存车牌照片";
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片"; case PRE_DEPARTURE_CHECK -> "保存装车完成照片";
case DRIVER_LICENSE -> "保存驾驶证附件照片"; case DRIVER_LICENSE -> "保存封车照片";
case RECEIPT -> "保存签收凭证照片"; case RECEIPT -> "保存BOL司机签字照片";
case FUEL_START -> "保存起始油耗表盘照片"; case FUEL_START -> "保存起始油耗表盘照片";
case FUEL_END -> "保存结束油耗表盘照片"; case FUEL_END -> "保存结束油耗表盘照片";
}; };
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.photo;
import org.springframework.web.multipart.MultipartFile;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** File按平台、档案类型、资源身份及文件身份隔离照片。 */
public interface FleetResourcePhotoGateway {
/** 上传平台所属的档案照片。 */
Photo upload(Kind kind, long id, String operator, MultipartFile file);
/** 核验档案类型、资源身份和文件身份。 */
Photo metadata(Kind kind, long id, long file);
/** 读取已核验归属的图片内容。 */
byte[] download(Kind kind, long id, long file);
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.photo;
import org.apache.ibatis.annotations.*;
/** 仅操作档案照片版本表;资源有效性通过所属领域服务核验。 */
@Mapper
public interface FleetResourcePhotoMapper {
/** 读取同一档案的最大照片版本。 */
@Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} ORDER BY record_version DESC LIMIT 1")
FleetResourcePhotoModels.Stored current(@Param("kind") String kind, @Param("id") long id);
/** 按档案与文件复合身份查询历史引用。 */
@Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} AND file_id=#{file} LIMIT 1")
FleetResourcePhotoModels.Stored file(@Param("kind") String kind, @Param("id") long id, @Param("file") long file);
/** 追加不可变照片版本,数据库拒绝重复资源版本。 */
@Insert("INSERT INTO ll_fleet_resource_photo(resource_kind,resource_id,record_version,file_id,file_name,content_type,size_bytes,created_by,created_at) VALUES(#{resourceKind},#{resourceId},#{recordVersion},#{fileId},#{fileName},#{contentType},#{sizeBytes},#{createdBy},#{createdAt})")
int insert(FleetResourcePhotoModels.Stored row);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.fleet.photo;
import java.time.LocalDateTime;
/** 档案照片独立版本化,历史图片不覆盖且不改变驾驶证审核结论。 */
public final class FleetResourcePhotoModels {
/** 仅承载照片接口与持久化数据。 */
private FleetResourcePhotoModels() { }
public enum Kind { VEHICLE, DRIVER }
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
public record Detail(long recordVersion, Photo photo) { }
public record Stored(String resourceKind, long resourceId, long recordVersion, long fileId,
String fileName, String contentType, long sizeBytes, String createdBy, LocalDateTime createdAt) { }
public record Download(Photo metadata, byte[] content) { }
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.photo;
import org.springframework.stereotype.Repository;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 同一资源版本唯一,阻止并发替换静默覆盖。 */
@Repository
public class FleetResourcePhotoRepository {
private final FleetResourcePhotoMapper mapper;
/** 注入唯一照片Mapper。 */
public FleetResourcePhotoRepository(FleetResourcePhotoMapper mapper) { this.mapper = mapper; }
/** 读取指定档案的最新照片版本。 */
public Stored current(Kind kind, long id) { return mapper.current(kind.name(), id); }
/** 按档案及文件身份读取不可变历史引用。 */
public Stored file(Kind kind, long id, long file) { return mapper.file(kind.name(), id, file); }
/** 追加新版本,唯一键负责并发冲突保护。 */
public void append(Stored row) { if (mapper.insert(row) != 1) throw new IllegalStateException("档案照片未保存"); }
}
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.fleet.photo;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 管理可重复用于排车的车辆及司机照片,排车另存当趟不可变副本。 */
@Service
public class FleetResourcePhotoService implements com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort {
private final FleetResourcePhotoRepository repository;
private final FleetResourcePhotoGateway files;
private final VehicleRepository vehicles;
private final DriverService drivers;
/** 通过资源领域入口确认身份,所有照片版本经Repository保存。 */
public FleetResourcePhotoService(FleetResourcePhotoRepository repository, FleetResourcePhotoGateway files,
VehicleRepository vehicles, DriverService drivers) {
this.repository=repository; this.files=files; this.vehicles=vehicles; this.drivers=drivers;
}
/** 先确认资源存在,空照片是明确的可补齐状态。 */
public Detail current(Kind kind, long id) {
requireResource(kind,id);
var row=repository.current(kind,id);
return row==null ? new Detail(0,null) : new Detail(row.recordVersion(),photo(row));
}
/** 版本随照片独立递增;失败保留旧照片及历史。 */
@Transactional
public Detail upload(Kind kind,long id,long version,String operator,MultipartFile file) {
var current=current(kind,id);
if(version!=current.recordVersion())throw conflict();
var saved=files.upload(kind,id,operator,file);
try {
repository.append(new Stored(kind.name(),id,version+1,Long.parseLong(saved.fileId()),saved.fileName(),
saved.contentType(),saved.sizeBytes(),operator,LocalDateTime.now(ZoneOffset.UTC)));
} catch(DuplicateKeyException error) { throw conflict(); }
return new Detail(version+1,saved);
}
/** 只允许读取本档案已关联的当前或历史图片。 */
public Download download(Kind kind,long id,long file) {
requireResource(kind,id);
if(repository.file(kind,id,file)==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"档案照片不存在");
var metadata=files.metadata(kind,id,file);
var bytes=files.download(kind,id,file);
if(bytes.length!=metadata.sizeBytes())throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"档案照片内容不完整");
return new Download(metadata,bytes);
}
/** 车辆及司机身份必须真实存在,不能按自由文本上传。 */
private void requireResource(Kind kind,long id) {
if(id<=0)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"档案身份无效");
if(kind==Kind.VEHICLE)vehicles.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,"车辆档案不存在")); else drivers.detail(id,null);
}
/** 转换为不暴露持久化信息的文件元数据。 */
private static Photo photo(Stored row) { return new Photo(Long.toString(row.fileId()),row.fileName(),row.contentType(),row.sizeBytes()); }
/** 冲突要求重读当前照片,不自动覆盖他人的修改。 */
private static ResponseStatusException conflict() { return new ResponseStatusException(HttpStatus.CONFLICT,"档案照片已变化,请重新读取后上传"); }
}
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.fleet.photo;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpFleetResourcePhotoGateway implements FleetResourcePhotoGateway {
private static final String PATH = "/internal/file/v1/business-files/0/{type}/{record}";
private static final long MAX_SIZE = 10L * 1024 * 1024;
private final RestClient client;
private final String token;
/** 使用统一内部服务客户端与File通信。 */
public HttpFleetResourcePhotoGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token;
}
/** 只接受非空图片,通过内部File上传并校验归属。 */
@Override public Photo upload(Kind kind, long recordId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("档案照片须为非空 JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try { body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType())); }
catch (IOException error) { throw bad("无法读取档案照片"); }
var result = client.post().uri(PATH, type(kind), recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class);
return verified(result, kind, recordId, null);
}
/** 逐文件核验平台及当前档案复合归属。 */
@Override public Photo metadata(Kind kind, long recordId, long fileId) {
return verified(client.get().uri(PATH + "/{file}", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), kind, recordId, fileId);
}
/** 按原档案身份读取图片,不返回匿名URL。 */
@Override public byte[] download(Kind kind, long recordId, long fileId) {
var response = client.get().uri(PATH + "/{file}/content", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class);
return imageContent(response);
}
/** 拒绝错误正文、空文件和超过上限的内容。 */
static byte[] imageContent(ResponseEntity<byte[]> response) {
byte[] content = response.getBody();
if (content == null || content.length == 0 || content.length > MAX_SIZE
|| !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
/** 验证File返回的身份、类型及字节数。 */
static Photo verified(Envelope result, Kind kind, long recordId, Long fileId) {
if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
var data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !type(kind).equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前资源档案或不是有效照片");
return new Photo(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
/** 档案类型不可由调用方拼接任意文件业务类别。 */
private static String type(Kind kind) { return kind == Kind.VEHICLE ? "FLEET_VEHICLE_PHOTO" : "FLEET_DRIVER_PHOTO"; }
/** 车辆和司机本人照片仅允许JPG及PNG。 */
private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); }
/** 返回可修正的附件业务错误。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
/** 外部失败保留诊断,不伪装为成功。 */
private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "档案照片不可用或归属校验失败"); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.fleet.photo;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.constraints.Min;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 复用车辆编辑、司机上传及各自列表权限,不扩大跨档案访问域。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}")
public class PlatformFleetResourcePhotoController {
private final FleetResourcePhotoService service;
/** 注入档案照片应用服务。 */
public PlatformFleetResourcePhotoController(FleetResourcePhotoService service) { this.service=service; }
/** 列表权限读取档案照片及版本。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')")
public ApiResponse<Detail> current(@PathVariable Kind kind,@PathVariable @Min(1) long id) {
return ApiResponse.success(service.current(kind,id));
}
/** 按资源类型校验上传权限并提交当前版本。 */
@PostMapping(consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("(#kind.name() == 'VEHICLE' and hasAuthority('platform:overseas-master-data:vehicle:edit')) or (#kind.name() == 'DRIVER' and hasAuthority('platform:overseas-master-data:driver:upload'))")
public ApiResponse<Detail> upload(@PathVariable Kind kind,@PathVariable @Min(1) long id,
@RequestParam @Min(0) long recordVersion,@RequestPart("file") MultipartFile file,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.upload(kind,id,recordVersion,user.userId().toString(),file));
}
/** 原档案读取权限包含其已关联的历史图片。 */
@GetMapping("/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')")
public ResponseEntity<byte[]> download(@PathVariable Kind kind,@PathVariable @Min(1) long id,@PathVariable @Min(1) long fileId) {
var file=service.download(kind,id,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.metadata().fileName(),StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
}
}

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