Compare commits

..
186 changed files with 9018 additions and 474 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)。
+16 -2
View File
@@ -6,6 +6,14 @@
货柜关联已放舱订舱时不再限制 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,准确单量以主单统计为准。
@@ -71,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` 拼写。
@@ -94,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 不算成功。
## 平台管理员密码 ## 平台管理员密码
@@ -194,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,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;
+122 -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,6 +4993,118 @@ 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='业务单据每日独立原子流水';
-- 海外费用资金及实收仓租。
-- 海外人工费用资金编排。金额与余额以 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 ( CREATE TABLE ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机', resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
@@ -5002,4 +5120,4 @@ CREATE TABLE ll_fleet_resource_photo (
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id) UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 235 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 240 AS initialized_tables;
+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, "内部服务凭证无效");
}
}
}
@@ -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) { }
}
@@ -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,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());
}
}
@@ -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;
+9 -2
View File
@@ -58,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`,车辆档案不含派送履约历史 |
@@ -164,3 +165,9 @@
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 - 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
- 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。 - 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。
### 海外人工收费首版切换
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购保留独立收费流程,禁止进入人工通用收费。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
@@ -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;不能仅发布前端。
自动验证覆盖方向持久化与幂等、国内仓主档校验、两段出口地址与交寄终态,以及页面切换方向后的仓库候选和旧请求重试。真实服务与人工截图验收另行记录。
@@ -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);
@@ -32,6 +32,7 @@ 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 DispatchMasterPhotoService masterPhotos;
@jakarta.annotation.Resource private DispatchNumberGenerator numbers; @jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@@ -377,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());
} }
@@ -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();
@@ -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);
}
@@ -17,13 +17,12 @@ public class TripOperationsRepository {
ORDER BY h.record_version LIMIT 1) AS snapshot_json, ORDER BY h.record_version LIMIT 1) AS snapshot_json,
(SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no (SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no
ORDER BY r.record_version DESC LIMIT 1) AS measured_json ORDER BY r.record_version DESC LIMIT 1) AS measured_json
FROM ll_fleet_trip t WHERE t.trip_purpose='DELIVERY' AND t.departed_at >= ? AND t.departed_at < ? FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ?
"""); """);
var args = new ArrayList<Object>(); var args = new ArrayList<Object>();
args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC));
if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); } if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); }
if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); } if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); }
// 派送统计只消费 DEPART 实载快照;取货与本土卡车使用各自的执行历史,不能混入此投影。
// Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot. // Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot.
sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001"); sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001");
return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"), return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"),
@@ -39,6 +39,8 @@ import java.util.stream.Collectors;
/** Converts final PDA package counts into inbound-order/pallet inventory. */ /** Converts final PDA package counts into inbound-order/pallet inventory. */
@Service @Service
public class InboundStockReceiptService { public class InboundStockReceiptService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@Resource private LastlegOrderMapper orders; @Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails; @Resource private LastlegOrderDetailMapper orderDetails;
@Resource private InboundStockReceiptMapper receipts; @Resource private InboundStockReceiptMapper receipts;
@@ -222,6 +224,7 @@ public class InboundStockReceiptService {
} }
} }
/** 真实库存入账后同步创建不可重置库龄的原计租批次。 */
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet, InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) { LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) {
@@ -295,6 +298,8 @@ public class InboundStockReceiptService {
receipt.setReceivedAt(receivedAt); receipt.setReceivedAt(receivedAt);
receipt.setCreatedAt(receivedAt); receipt.setCreatedAt(receivedAt);
receipts.insert(receipt); receipts.insert(receipt);
// 实际实收与原计租批次同事务落地,重复凭证不增加租金基数。
storageRent.registerInbound(receipt, order);
return receipt; return receipt;
} }
@@ -1,9 +1,6 @@
package com.baiyue.lastleg.inboundplan.service; package com.baiyue.lastleg.inboundplan.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -27,8 +24,6 @@ public class DestuffingPalletCostService {
static final String RECEIVED_PALLET_IDS_SQL = "SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL AND pp.actual_package_count>0 AND pp.actual_volume_cbm>0 ORDER BY pp.warehouse_pallet_id"; static final String RECEIVED_PALLET_IDS_SQL = "SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL AND pp.actual_package_count>0 AND pp.actual_volume_cbm>0 ORDER BY pp.warehouse_pallet_id";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final PalletPhysicalGateway pallets; private final PalletPhysicalGateway pallets;
private final OverseasFeeSourceMapper feeSources;
private final OverseasFeeOutboxService feeOutbox;
public List<String> readyBookingIds() { public List<String> readyBookingIds() {
return jdbc.queryForList(READY_BOOKINGS_SQL, String.class); return jdbc.queryForList(READY_BOOKINGS_SQL, String.class);
@@ -59,10 +54,9 @@ public class DestuffingPalletCostService {
for (int i=0; i<ordered.size(); i++) { for (int i=0; i<ordered.size(); i++) {
CustomerVolume volume = ordered.get(i); CustomerVolume volume = ordered.get(i);
BigDecimal amount = amounts.get(i); BigDecimal amount = amounts.get(i);
OverseasFeeSource fee = fee(costId, bookingId, containerNo, warehouseId, volume, amount, operatorId); // 保留实际成本分摊供人工核价,不自动生成客户应收。
feeSources.insert(fee); feeOutbox.publish(fee, "PUBLISHED"); jdbc.update("INSERT INTO ll_destuff_pallet_cost_allocation(id,cost_id,customer_id,actual_volume_cbm,allocated_amount,fee_source_id,created_at) VALUES(?,?,?,?,?,NULL,NOW(3))",
jdbc.update("INSERT INTO ll_destuff_pallet_cost_allocation(id,cost_id,customer_id,actual_volume_cbm,allocated_amount,fee_source_id,created_at) VALUES(?,?,?,?,?,?,NOW(3))", IdWorker.getId(), costId, volume.customerId(), volume.volume(), amount);
IdWorker.getId(), costId, volume.customerId(), volume.volume(), amount, fee.getSourceFeeId());
} }
} }
@@ -78,20 +72,5 @@ public class DestuffingPalletCostService {
return result; return result;
} }
private OverseasFeeSource fee(long costId, String bookingId, String containerNo, Long warehouseId, CustomerVolume volume, BigDecimal amount, String operator) {
LocalDateTime now=LocalDateTime.now(ZoneOffset.UTC); OverseasFeeSource v=new OverseasFeeSource();
v.setId(IdWorker.getId()); v.setSourceFeeId(IdWorker.getId()); v.setSourceVersion(1); v.setCustomerId(volume.customerId());
v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType(DESTUFF_BUSINESS_TYPE);
v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo);
v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE");
v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount);
v.setOriginalCurrency("CNY"); v.setOriginalAmount(amount); v.setExchangeRate(BigDecimal.ONE.setScale(6));
v.setExchangeRateMonth(LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")));
v.setChargeableQuantity(volume.volume()); v.setQuantityUnit("CBM");
v.setUnitPrice(volume.volume().signum() > 0 ? amount.divide(volume.volume(), 6, RoundingMode.HALF_UP) : null);
v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator);
v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v;
}
private static Long parseLong(String value){try{return Long.parseLong(value);}catch(Exception ignored){return null;}}
private record CustomerVolume(String customerId, BigDecimal volume){} private record CustomerVolume(String customerId, BigDecimal volume){}
} }
@@ -60,9 +60,9 @@ public class PlatformOverseasProcurementController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
@Operation(summary = "List procurement warehouse options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST) @Operation(summary = "List procurement warehouse options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
public ApiResponse<List<OverseasProcurementResponses.WarehouseOption>> warehouseOptions( public ApiResponse<List<OverseasProcurementResponses.WarehouseOption>> warehouseOptions(
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String procurementDirection) {
requirePlatform(user); requirePlatform(user);
return ApiResponse.success(service.warehouseOptions()); return ApiResponse.success(service.warehouseOptions(procurementDirection));
} }
@GetMapping("/{orderNo}") @GetMapping("/{orderNo}")
@@ -28,7 +28,15 @@ public final class OverseasProcurementRequests {
@NotBlank @Size(max = 128) String contactName, @NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone, @NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone,
@Size(max = 1000) String remark, @Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) { @NotBlank @Size(max = 255) String idempotencyKey,
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
@jakarta.validation.constraints.Pattern(regexp="OVERSEAS_TO_DOMESTIC|DOMESTIC_TO_OVERSEAS") String procurementDirection) {
public Create(String customerName, String purchaseCategory, String productName, String productSpecification,
String productUrl, Long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
String receivingAddress, String contactName, String contactPhone, String remark, String idempotencyKey) {
this(customerName, purchaseCategory, productName, productSpecification, productUrl, purchaseQuantity,
budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, remark, idempotencyKey, null);
}
@com.fasterxml.jackson.annotation.JsonAnySetter @com.fasterxml.jackson.annotation.JsonAnySetter
public void rejectUnknownField(String name, JsonNode value) { public void rejectUnknownField(String name, JsonNode value) {
throw new IllegalArgumentException("采购下单仅支持单商品、单规格及一次购买批次,不支持扩展字段:" + name); throw new IllegalArgumentException("采购下单仅支持单商品、单规格及一次购买批次,不支持扩展字段:" + name);
@@ -57,7 +65,13 @@ public final class OverseasProcurementRequests {
@NotBlank String decision, @NotBlank String decision,
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark, @Size(max = 1000) String remark,
@Positive Long purchaseWarehouseId) {} @Positive Long purchaseWarehouseId,
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
@jakarta.validation.constraints.Pattern(regexp="OVERSEAS_TO_DOMESTIC|DOMESTIC_TO_OVERSEAS") String procurementDirection) {
public Review(Long version, String decision, String idempotencyKey, String remark, Long purchaseWarehouseId) {
this(version, decision, idempotencyKey, remark, purchaseWarehouseId, null);
}
}
public record Update( public record Update(
@NotNull @Min(0) Long version, @NotNull @Min(0) Long version,
@@ -12,7 +12,7 @@ public final class OverseasProcurementResponses {
String purchaseCategory, String productName, String productSpecification, String purchaseCategory, String productName, String productSpecification,
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
String purchaseWarehouseId, String purchaseWarehouseName, String purchaseWarehouseId, String purchaseWarehouseName,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, String procurementDirection) {}
public record CustomerOption(String customerId, String customerName) {} public record CustomerOption(String customerId, String customerName) {}
@@ -17,6 +17,7 @@ public class OverseasProcurementOrder {
private String customerId; private String customerId;
private String customerName; private String customerName;
private String orderNo; private String orderNo;
private String procurementDirection;
private String purchaseCategory; private String purchaseCategory;
private String productName; private String productName;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification;
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.procurement.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 方向变更只读门禁,购买与收货事实仍归原履约模型。 */
@Mapper
public interface ProcurementDirectionMapper {
/** 已发生的购买或起运仓事实禁止通过审核切换方向。 */
@Select("SELECT EXISTS(SELECT 1 FROM ll_procurement_flow WHERE order_id=#{orderId} AND "
+ "(purchased_at IS NOT NULL OR actual_warehouse_id IS NOT NULL OR overseas_shipped_at IS NOT NULL))")
boolean hasStarted(@Param("orderId") long orderId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.procurement.repository;
import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
/** 采购方向审核的数据访问边界。 */
@Repository
@RequiredArgsConstructor
public class ProcurementDirectionRepository {
private final ProcurementDirectionMapper mapper;
/** 在订单行锁内读取履约事实,避免改向重解释原始货物来源。 */
public boolean hasStarted(long orderId) { return mapper.hasStarted(orderId); }
}
@@ -50,12 +50,13 @@ public class OverseasProcurementService {
public static final String WAIT_PURCHASE = "WAIT_PURCHASE"; public static final String WAIT_PURCHASE = "WAIT_PURCHASE";
public static final String REJECTED = "REJECTED"; public static final String REJECTED = "REJECTED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "WAIT_OVERSEAS_RECEIPT", private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "WAIT_OVERSEAS_RECEIPT",
"OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "INTERNATIONAL_TRANSIT", "OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "WAIT_OVERSEAS_SHIPMENT", "INTERNATIONAL_TRANSIT",
"DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED"); "DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "WAIT_CUSTOMER_SHIPMENT", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED");
@Resource private OverseasProcurementOrderMapper orders; @Resource private OverseasProcurementOrderMapper orders;
@Resource private OverseasProcurementStatusHistoryMapper histories; @Resource private OverseasProcurementStatusHistoryMapper histories;
@Resource private ProcurementWarehouseDirectory warehouseDirectory; @Resource private ProcurementWarehouseDirectory warehouseDirectory;
@Resource private com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private OverseasProcurementFileMapper procurementFiles; @Resource private OverseasProcurementFileMapper procurementFiles;
@Resource private OverseasProcurementFeeMapper procurementFees; @Resource private OverseasProcurementFeeMapper procurementFees;
@@ -76,6 +77,7 @@ public class OverseasProcurementService {
order.setCustomerId(customerId); order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName())); order.setCustomerName(trim(request.customerName()));
order.setOrderNo(orderNoGenerator.next("CG")); order.setOrderNo(orderNoGenerator.next("CG"));
order.setProcurementDirection(ProcurementDirection.normalize(request.procurementDirection()));
order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim()); order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim());
order.setProductName(request.productName().trim()); order.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification())); order.setProductSpecification(trim(request.productSpecification()));
@@ -115,8 +117,12 @@ public class OverseasProcurementService {
.toList(); .toList();
} }
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() { public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() { return warehouseOptions(null); }
return warehouseDirectory.options().stream()
/** 按采购方向读取权威起运收货仓目录。 */
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions(String direction) {
return (ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction))
? warehouseDirectory.options(direction) : warehouseDirectory.options()).stream()
.map(value -> new OverseasProcurementResponses.WarehouseOption(String.valueOf(value.warehouseId()), .map(value -> new OverseasProcurementResponses.WarehouseOption(String.valueOf(value.warehouseId()),
value.warehouseCode(), value.warehouseName(), value.countryCode())) value.warehouseCode(), value.warehouseName(), value.countryCode()))
.toList(); .toList();
@@ -226,12 +232,23 @@ public class OverseasProcurementService {
} }
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
String direction = request.procurementDirection() == null ? ProcurementDirection.normalize(order.getProcurementDirection())
: ProcurementDirection.normalize(request.procurementDirection());
if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection()))) {
throw conflict("审核不能修改采购方向,请按客户下单方向选择采购仓");
}
if (REJECTED.equals(next) && trim(request.remark()) == null) {
throw badRequest("请填写拒绝原因");
}
Map<String, Object> before = reviewSnapshot(order);
if (WAIT_PURCHASE.equals(next)) { if (WAIT_PURCHASE.equals(next)) {
if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择有效海外收货仓"); if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择与采购方向匹配的收货仓");
var warehouse = warehouseDirectory.required(request.purchaseWarehouseId()); var warehouse = ProcurementDirection.EXPORT.equals(direction)
? warehouseDirectory.required(request.purchaseWarehouseId(), direction) : warehouseDirectory.required(request.purchaseWarehouseId());
order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId())); order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));
order.setPurchaseWarehouseName(warehouse.warehouseName()); order.setPurchaseWarehouseName(warehouse.warehouseName());
} }
order.setProcurementDirection(direction);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
order.setStatus(next); order.setStatus(next);
order.setReviewedBy(operatorId); order.setReviewedBy(operatorId);
@@ -242,7 +259,7 @@ public class OverseasProcurementService {
touch(order, now); touch(order, now);
orders.updateById(order); orders.updateById(order);
history(order, "REVIEW", PENDING_REVIEW, next, operatorId, history(order, "REVIEW", PENDING_REVIEW, next, operatorId,
key, null, request.remark(), now); key, changeDetail(before, reviewSnapshot(order)), request.remark(), now);
return detail(customerId, orderNo); return detail(customerId, orderNo);
} }
@@ -282,7 +299,7 @@ public class OverseasProcurementService {
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(),
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(),
displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), ProcurementDirection.normalize(order.getProcurementDirection()));
} }
private void history(OverseasProcurementOrder order, String operationType, String from, String to, private void history(OverseasProcurementOrder order, String operationType, String from, String to,
@@ -303,6 +320,15 @@ public class OverseasProcurementService {
histories.insert(history); histories.insert(history);
} }
/** 审核流水记录采购方向和收货仓变更,不仅记录状态。 */
private Map<String, Object> reviewSnapshot(OverseasProcurementOrder order) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("procurementDirection", ProcurementDirection.normalize(order.getProcurementDirection()));
snapshot.put("purchaseWarehouseId", order.getPurchaseWarehouseId());
snapshot.put("purchaseWarehouseName", order.getPurchaseWarehouseName());
return snapshot;
}
private void validateQuery(OverseasProcurementRequests.Query query) { private void validateQuery(OverseasProcurementRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) { if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) {
throw badRequest("Unsupported procurement status"); throw badRequest("Unsupported procurement status");
@@ -47,7 +47,7 @@ public class ProcurementControlService {
if(lifecycle.replay(order,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request))return snapshot(order); if(lifecycle.replay(order,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());proof(order,request.voucherFileId());var flow=lifecycle.flow(order.getId()); version(order,request.version());proof(order,request.voucherFileId());var flow=lifecycle.flow(order.getId());
if(flow.get("actual_warehouse_id")==null)throw conflict("尚无实际收货仓,请修改计划仓"); if(flow.get("actual_warehouse_id")==null)throw conflict("尚无实际收货仓,请修改计划仓");
var warehouse=warehouses.required(request.warehouseId()); var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId()));
long old=((Number)flow.get("actual_warehouse_id")).longValue();if(old==request.warehouseId())throw bad("更正仓库与原仓库相同"); long old=((Number)flow.get("actual_warehouse_id")).longValue();if(old==request.warehouseId())throw bad("更正仓库与原仓库相同");
Integer downstream=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_carton WHERE order_id=?",Integer.class,order.getId()); Integer downstream=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_carton WHERE order_id=?",Integer.class,order.getId());
boolean appendOnly=downstream>0 || flow.get("overseas_shipped_at")!=null || Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED").contains(order.getStatus()); boolean appendOnly=downstream>0 || flow.get("overseas_shipped_at")!=null || Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED").contains(order.getStatus());
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.procurement.service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/** 采购方向决定起运仓类型,历史两段编码分别代表起运段和目的地段。 */
public final class ProcurementDirection {
public static final String IMPORT = "OVERSEAS_TO_DOMESTIC";
public static final String EXPORT = "DOMESTIC_TO_OVERSEAS";
private ProcurementDirection() { }
/** 旧订单缺少方向时保持原进口流程。 */
public static String normalize(String value) {
if (value == null) return IMPORT;
if (!IMPORT.equals(value) && !EXPORT.equals(value)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购方向无效");
return value;
}
/** 展示区域随方向切换,不改历史技术段编码和关联。 */
public static String display(String text, String direction) {
if (text == null || !EXPORT.equals(normalize(direction))) return text;
return text.replace("海外", "__ORIGIN__").replace("国内", "海外").replace("__ORIGIN__", "国内");
}
}
@@ -124,7 +124,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
fee.setFeeNo(documentNumbers.next("PF")); fee.setFeeNo(documentNumbers.next("PF"));
fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1); fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1);
fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code); fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code);
fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label()); fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : ProcurementDirection.display(request.purpose().label(), order.getProcurementDirection()));
fee.setOriginalCurrency(conversion.originalCurrency()); fee.setOriginalAmount(conversion.originalAmount()); fee.setOriginalCurrency(conversion.originalCurrency()); fee.setOriginalAmount(conversion.originalAmount());
fee.setExchangeRate(conversion.exchangeRate()); fee.setExchangeRateMonth(conversion.exchangeRateMonth()); fee.setExchangeRate(conversion.exchangeRate()); fee.setExchangeRateMonth(conversion.exchangeRateMonth());
fee.setAmount(conversion.settlementAmount()); fee.setCurrency("CNY"); fee.setBusinessDate(request.businessDate()); fee.setAmount(conversion.settlementAmount()); fee.setCurrency("CNY"); fee.setBusinessDate(request.businessDate());
@@ -450,7 +450,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
private void publishSource(OverseasProcurementOrder order, OverseasProcurementFee fee) { private void publishSource(OverseasProcurementOrder order, OverseasProcurementFee fee) {
var request = new OverseasFeeSourceDtos.SaveRequest(fee.getCustomerId(), order.getCustomerName(), var request = new OverseasFeeSourceDtos.SaveRequest(fee.getCustomerId(), order.getCustomerName(),
"OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getWalletBusinessId(), fee.getWalletBusinessNo(), "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getWalletBusinessId(), fee.getWalletBusinessNo(),
order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), null, "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), fee.getBusinessDate(), fee.getOriginalCurrency(), null, null, null, null, null, null, null, List.of(), fee.getBusinessDate(), fee.getOriginalCurrency(),
fee.getOriginalAmount(), BigDecimal.ONE, "ITEM", fee.getOriginalAmount(), null, null); fee.getOriginalAmount(), BigDecimal.ONE, "ITEM", fee.getOriginalAmount(), null, null);
var source = sources.publishSettled(fee.getCreatedBy(), "PROCUREMENT:" + fee.getFeeNo(), request, var source = sources.publishSettled(fee.getCreatedBy(), "PROCUREMENT:" + fee.getFeeNo(), request,
@@ -460,14 +460,13 @@ public class ProcurementFinanceService implements WalletResultHandler {
fee.setSourceFeeId(source.getSourceFeeId()); fee.setSourceFeeId(source.getSourceFeeId());
} }
/** 余额实扣并登记本地费用来源后即可履约,无需等待财务账单推送。 */
public boolean sourceLinked(OverseasProcurementFee fee) { public boolean sourceLinked(OverseasProcurementFee fee) {
if(fee==null || fee.getWalletStatus()==null)return false; if(fee==null || fee.getWalletStatus()==null)return false;
if (Set.of("ZERO", "NOT_APPLICABLE", "REFUNDED").contains(fee.getWalletStatus())) return true; if (Set.of("ZERO", "NOT_APPLICABLE", "REFUNDED").contains(fee.getWalletStatus())) return true;
if ("RELEASED".equals(fee.getWalletStatus()) && fee.getAmount().signum() == 0) return true; if ("RELEASED".equals(fee.getWalletStatus()) && fee.getAmount().signum() == 0) return true;
if (!"SETTLED".equals(fee.getWalletStatus()) || fee.getSourceFeeId() == null) return false; return "SETTLED".equals(fee.getWalletStatus()) && fee.getSourceFeeId() != null
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox WHERE aggregate_type='OVERSEAS_PROCUREMENT_FEE' AND aggregate_id=? AND status='PUBLISHED'", && fee.getWalletLedgerId() != null && fee.getSettledCommandEventId() != null;
Integer.class, String.valueOf(fee.getSourceFeeId()));
return count != null && count > 0;
} }
/** Called under the procurement order lock before the final physical handoff or exception closure. */ /** Called under the procurement order lock before the final physical handoff or exception closure. */
@@ -106,7 +106,7 @@ public class ProcurementLifecycleService {
if(replay(order,"PLAN_WAREHOUSE",request.idempotencyKey(),request)) return outcome(order); if(replay(order,"PLAN_WAREHOUSE",request.idempotencyKey(),request)) return outcome(order);
version(order,request.version());open(order);ensureFlow(order); version(order,request.version());open(order);ensureFlow(order);
if(flow(order.getId()).get("actual_warehouse_id")!=null) throw conflict("首次实收已冻结实际仓库,请使用事实更正操作"); if(flow(order.getId()).get("actual_warehouse_id")!=null) throw conflict("首次实收已冻结实际仓库,请使用事实更正操作");
var warehouse=warehouses.required(request.warehouseId()); var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId()));
order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));order.setPurchaseWarehouseName(warehouse.warehouseName()); order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));order.setPurchaseWarehouseName(warehouse.warehouseName());
complete(order,operator,"PLAN_WAREHOUSE",request.idempotencyKey(),request.reason(),request); complete(order,operator,"PLAN_WAREHOUSE",request.idempotencyKey(),request.reason(),request);
return outcome(order); return outcome(order);
@@ -109,7 +109,7 @@ public class ProcurementPhysicalService {
LocalDateTime originAt; LocalDateTime originAt;
if(request.leg()==Leg.OVERSEAS) { if(request.leg()==Leg.OVERSEAS) {
if(request.warehouseId()==null || request.sourceCartonId()!=null) throw bad("海外实收须选择实际收货仓,不能指定国内来源箱"); if(request.warehouseId()==null || request.sourceCartonId()!=null) throw bad("海外实收须选择实际收货仓,不能指定国内来源箱");
var warehouse=warehouses.required(request.warehouseId()); var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId()));
if(flow.get("actual_warehouse_id")!=null && ((Number)flow.get("actual_warehouse_id")).longValue()!=request.warehouseId()) throw conflict("本单实际收货仓已冻结,不支持转仓"); if(flow.get("actual_warehouse_id")!=null && ((Number)flow.get("actual_warehouse_id")).longValue()!=request.warehouseId()) throw conflict("本单实际收货仓已冻结,不支持转仓");
jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?", jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?",
warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId()); warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId());
@@ -29,7 +29,7 @@ public class ProcurementQueryService {
String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency, String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency,
String receivingAddress,String contactName,String contactPhone,LocalDateTime estimatedArrivalAt, String receivingAddress,String contactName,String contactPhone,LocalDateTime estimatedArrivalAt,
String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version, String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version,
LocalDateTime createdAt,LocalDateTime updatedAt) { } LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { }
public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { } public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { }
public record Progress(String key,String title,LocalDateTime completedAt) { } public record Progress(String key,String title,LocalDateTime completedAt) { }
public record Box(String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,BigDecimal grossWeightKg) { } public record Box(String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,BigDecimal grossWeightKg) { }
@@ -60,14 +60,14 @@ public class ProcurementQueryService {
var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map<Long,String> feeNos=new HashMap<>();fees.forEach(f->feeNos.put(f.getId(),f.getFeeNo())); var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map<Long,String> feeNos=new HashMap<>();fees.forEach(f->feeNos.put(f.getId(),f.getFeeNo()));
var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),order.getProductSpecification(),order.getProductUrl(), var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),order.getProductSpecification(),order.getProductUrl(),
order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(), order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(),
(String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt()); (String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection()));
var budgets=jdbc.query("SELECT authorized_amount,currency,reviewed_at FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1", var budgets=jdbc.query("SELECT authorized_amount,currency,reviewed_at FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1",
(rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId()); (rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId());
var budget=budgets.isEmpty()?new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null):budgets.get(0); var budget=budgets.isEmpty()?new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null):budgets.get(0);
List<Progress> progress=new ArrayList<>();progress.add(new Progress("CREATED","客户下单",order.getCreatedAt()));progress.add(new Progress("REVIEWED","采购审核",order.getReviewedAt())); List<Progress> progress=new ArrayList<>();progress.add(new Progress("CREATED","客户下单",order.getCreatedAt()));progress.add(new Progress("REVIEWED","采购审核",order.getReviewedAt()));
for(var step:List.of(new String[]{"purchased_at","购买完成"},new String[]{"overseas_received_at","海外收货完成"},new String[]{"overseas_shipped_at","海外实际交寄"}, for(var step:List.of(new String[]{"purchased_at","购买完成"},new String[]{"overseas_received_at","海外收货完成"},new String[]{"overseas_shipped_at","海外实际交寄"},
new String[]{"domestic_received_at","国内收货完成"},new String[]{"charges_confirmed_at","国内费用核定"},new String[]{"customer_shipped_at","已寄客户"},new String[]{"closed_at","异常关闭"})) { new String[]{"domestic_received_at","国内收货完成"},new String[]{"charges_confirmed_at","国内费用核定"},new String[]{"customer_shipped_at","已寄客户"},new String[]{"closed_at","异常关闭"})) {
progress.add(new Progress(step[0],step[1],date(flow.get(step[0])))); progress.add(new Progress(step[0],ProcurementDirection.display(step[1],order.getProcurementDirection()),date(flow.get(step[0]))));
} }
var manifest=physical.currentManifest(order.getId()); var manifest=physical.currentManifest(order.getId());
boolean confirmed=manifest!=null && "CONFIRMED".equals(manifest.get("status")); boolean confirmed=manifest!=null && "CONFIRMED".equals(manifest.get("status"));
@@ -124,7 +124,8 @@ public class ProcurementQueryService {
action(result,permissions,"FINANCE_ADJUST","费用更正与退款","finance-adjust",null); action(result,permissions,"FINANCE_ADJUST","费用更正与退款","finance-adjust",null);
action(result,permissions,"CANCEL","购买前取消","cancel",closed?stopped:purchased?"购买后不能取消,请处理实物资金后异常关闭":null); action(result,permissions,"CANCEL","购买前取消","cancel",closed?stopped:purchased?"购买后不能取消,请处理实物资金后异常关闭":null);
action(result,permissions,"EXCEPTION_CLOSE","异常关闭","manage",closed?stopped:!purchased?"购买前请取消订单":blockers!=null?blockers:reason(()->finance.requireShipmentReady(order.getId()))); action(result,permissions,"EXCEPTION_CLOSE","异常关闭","manage",closed?stopped:!purchased?"购买前请取消订单":blockers!=null?blockers:reason(()->finance.requireShipmentReady(order.getId())));
return List.copyOf(result); return result.stream().map(a -> new Action(a.key(), ProcurementDirection.display(a.label(), order.getProcurementDirection()),
a.permission(), a.allowed(), ProcurementDirection.display(a.reason(), order.getProcurementDirection()))).toList();
} }
private static void action(List<Action> result,Set<String> permissions,String key,String label,String suffix,String block){String code="platform:overseas-business:purchase:"+suffix;String reason=permissions==null || !permissions.contains(code)?"无此操作权限":block;result.add(new Action(key,label,code,reason==null,reason));} private static void action(List<Action> result,Set<String> permissions,String key,String label,String suffix,String block){String code="platform:overseas-business:purchase:"+suffix;String reason=permissions==null || !permissions.contains(code)?"无此操作权限":block;result.add(new Action(key,label,code,reason==null,reason));}
private static String reason(Runnable action){try{action.run();return null;}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}} private static String reason(Runnable action){try{action.run();return null;}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}}
@@ -52,7 +52,7 @@ public class ProcurementTransportService {
if(request.leg()==Leg.OVERSEAS) { if(request.leg()==Leg.OVERSEAS) {
var id=(Number)lifecycle.flow(order.getId()).get("actual_warehouse_id"); var id=(Number)lifecycle.flow(order.getId()).get("actual_warehouse_id");
if(id==null)throw conflict("尚无实际海外收货仓"); if(id==null)throw conflict("尚无实际海外收货仓");
sender=warehouses.sender(id.longValue()); sender=ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.sender(id.longValue(), order.getProcurementDirection()) : warehouses.sender(id.longValue());
} else { } else {
var original=internationalAddresses(order); var original=internationalAddresses(order);
sender=original.receiver(); sender=original.receiver();
@@ -18,6 +18,26 @@ public class ProcurementWarehouseDirectory {
if(result==null || !result.successful() || result.data()==null) throw unavailable(); if(result==null || !result.successful() || result.data()==null) throw unavailable();
return result.data(); return result.data();
} }
/** 国内采购消费国内仓目录,旧方向仍使用原海外收货仓目录。 */
public List<DomesticWarehouseDirectoryClient.ReceivingWarehouse> options(String direction) {
if (!ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction))) return options();
var result = client.domesticWarehouses(token);
if (result == null || !result.successful() || result.data() == null) throw unavailable();
return result.data().stream().map(w -> new DomesticWarehouseDirectoryClient.ReceivingWarehouse(
w.id(), w.warehouseCode(), w.warehouseName(), "CN", "ACTIVE")).toList();
}
/** 写入前核验公共作用域、启用状态和仓库类型,不信任前端候选。 */
public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id, String direction) {
if (!ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction))) return required(id);
var result = client.activeWarehouse(id, token);
if (result == null || !result.successful() || result.data() == null || result.data().warehouse() == null) throw unavailable();
var w = result.data().warehouse();
if (!Long.valueOf(id).equals(w.id()) || !Long.valueOf(0).equals(w.tenantId())
|| !"DOMESTIC".equals(w.warehouseType()) || !"ACTIVE".equals(w.status())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "国内采购须选择有效国内公共仓");
}
return new DomesticWarehouseDirectoryClient.ReceivingWarehouse(w.id(), w.warehouseCode(), w.warehouseName(), w.countryCode(), w.status());
}
public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id) { public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id) {
var result=client.receivingWarehouse(id,token); var result=client.receivingWarehouse(id,token);
if(result==null || !result.successful() || result.data()==null) throw unavailable(); if(result==null || !result.successful() || result.data()==null) throw unavailable();
@@ -28,19 +48,23 @@ public class ProcurementWarehouseDirectory {
return warehouse; return warehouse;
} }
public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id) { public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id) {
required(id); return sender(id, ProcurementDirection.IMPORT);
}
/** 从起运仓主档生成发件信息,两种方向均校验作业地址。 */
public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id, String direction) {
required(id, direction);
var result=client.activeWarehouse(id,token); var result=client.activeWarehouse(id,token);
if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable(); if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable();
var warehouse=result.data().warehouse(); var warehouse=result.data().warehouse();
if(!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId()) if(!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId())
|| !"OVERSEAS".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) throw unavailable(); || !(ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction)) ? "DOMESTIC" : "OVERSEAS").equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) throw unavailable();
var addresses=result.data().addresses(); var addresses=result.data().addresses();
var operating=addresses==null?List.<DomesticWarehouseDirectoryClient.Address>of():addresses.stream() var operating=addresses==null?List.<DomesticWarehouseDirectoryClient.Address>of():addresses.stream()
.filter(a->"OPERATING".equals(a.addressType())).toList(); .filter(a->"OPERATING".equals(a.addressType())).toList();
if(operating.size()!=1 || warehouse.contactName()==null || warehouse.contactName().isBlank() if(operating.size()!=1 || warehouse.contactName()==null || warehouse.contactName().isBlank()
|| warehouse.contactPhone()==null || !warehouse.contactPhone().matches("[+0-9 ()-]{5,64}") || warehouse.contactPhone()==null || !warehouse.contactPhone().matches("[+0-9 ()-]{5,64}")
|| operating.get(0).addressLine1()==null || operating.get(0).addressLine1().isBlank()) { || operating.get(0).addressLine1()==null || operating.get(0).addressLine1().isBlank()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,"实际海外仓的联系人、电话或唯一作业地址不完整,请先维护仓库资料"); throw new ResponseStatusException(HttpStatus.CONFLICT,"实际起运仓的联系人、电话或唯一作业地址不完整,请先维护仓库资料");
} }
var a=operating.get(0); var a=operating.get(0);
String address=java.util.stream.Stream.of(a.countryCode(),a.stateProvince(),a.city(),a.district(),a.addressLine1(),a.addressLine2(),a.postalCode()) String address=java.util.stream.Stream.of(a.countryCode(),a.stateProvince(),a.city(),a.district(),a.addressLine1(),a.addressLine2(),a.postalCode())
@@ -48,6 +72,6 @@ public class ProcurementWarehouseDirectory {
return new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address(warehouse.contactName(),warehouse.contactPhone(),address); return new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address(warehouse.contactName(),warehouse.contactPhone(),address);
} }
private static ResponseStatusException unavailable() { private static ResponseStatusException unavailable() {
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"海外收货仓目录暂不可用,请稍后重试"); return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"采购收货仓目录暂不可用,请稍后重试");
} }
} }
@@ -490,7 +490,7 @@ public class RelabelApplicationService implements WalletResultHandler {
String operationWarehouseId = request.operationWarehouseId().trim(); String operationWarehouseId = request.operationWarehouseId().trim();
String relabelNo = compactNumbers.next("TB"); String relabelNo = compactNumbers.next("TB");
BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO); BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO);
boolean requiresWallet = chargeAmount.signum() > 0; // 首版仅保留客户报价快照;应收须在海外费用登记并审核。
int holdIndex = 0; int holdIndex = 0;
for (SourceInventory source : sources) { for (SourceInventory source : sources) {
if (source.returnCarton() != null) continue; if (source.returnCarton() != null) continue;
@@ -524,7 +524,7 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setNotifiedPackageCount(notified); application.setNotifiedPackageCount(notified);
application.setChargeAmount(chargeAmount); application.setChargeAmount(chargeAmount);
application.setChargeCurrency("CNY"); application.setChargeCurrency("CNY");
application.setWalletStatus(requiresWallet ? "FREEZE_PENDING" : "NOT_REQUIRED"); application.setWalletStatus("NOT_REQUIRED");
application.setIdempotencyKey(request.idempotencyKey().trim()); application.setIdempotencyKey(request.idempotencyKey().trim());
application.setRequestHash(requestHash); application.setRequestHash(requestHash);
application.setCreatedBy(trim(operatorId)); application.setCreatedBy(trim(operatorId));
@@ -550,12 +550,6 @@ public class RelabelApplicationService implements WalletResultHandler {
} }
} }
if (requiresWallet) {
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, chargeAmount, "换标订单费用冻结", 1L);
application.setWalletCommandEventId(walletEventId);
applications.updateById(application);
}
for (SourceInventory source : sources) { for (SourceInventory source : sources) {
insertItem(customerId, applicationId, source, now); insertItem(customerId, applicationId, source, now);
@@ -876,20 +870,10 @@ public class RelabelApplicationService implements WalletResultHandler {
private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId, private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId,
List<LastlegOrder> linkedOrders) { List<LastlegOrder> linkedOrders) {
if (Set.of("NOT_REQUIRED", "SETTLE_PENDING", "SETTLED").contains(Objects.toString(application.getWalletStatus(), ""))) return; if (Set.of("NOT_REQUIRED", "SETTLE_PENDING", "SETTLED").contains(Objects.toString(application.getWalletStatus(), ""))) return;
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0)); if (!"FROZEN".equals(application.getWalletStatus())) throw conflict("旧换标费用尚未锁定,请先处理资金状态");
String customerId = application.getCustomerId(), relabelNo = application.getRelabelNo(); String eventId = walletCommands.publish("SETTLE", Long.parseLong(application.getCustomerId()), "RELABEL_ORDER", application.getRelabelNo(), null,
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
application.getLastlegOrderId(), null, null, null, null, null,
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
application.getChargeCurrency(), application.getChargeAmount(),
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
null, null));
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L); "换标完成,结算费用", 2L);
application.setWalletCommandEventId(eventId);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
} }
@@ -1298,9 +1282,19 @@ public class RelabelApplicationService implements WalletResultHandler {
@Override @Override
@Transactional @Transactional
public void handle(WalletCommandCompletedV1 result) { public void handle(WalletCommandCompletedV1 result) {
RelabelApplication value = applications.findNo(String.valueOf(result.customerId()), result.businessNo()); RelabelApplication value = applications.lockNo(String.valueOf(result.customerId()), result.businessNo());
if (value == null) throw notFound("Relabel application not found for wallet result"); if (value == null) throw notFound("Relabel application not found for wallet result");
if ("NOT_REQUIRED".equals(value.getWalletStatus())) throw conflict("本换标单不使用旧资金流程");
if ("SETTLE".equals(result.operation()) && "SETTLED".equals(value.getWalletStatus())) return;
if (value.getWalletCommandEventId() != null && !Objects.equals(value.getWalletCommandEventId(), result.commandEventId()))
throw conflict("资金回执不属于当前指令");
if ("SUCCEEDED".equals(result.result())) { if ("SUCCEEDED".equals(result.result())) {
if ("SETTLE".equals(result.operation())) {
if (!"SETTLE_PENDING".equals(value.getWalletStatus()) || !"SETTLED".equals(result.holdStatus())
|| result.walletHoldId() == null || result.walletLedgerId() == null || result.amount() == null
|| result.amount().compareTo(value.getChargeAmount()) != 0) throw conflict("换标扣款回执不完整");
publishLegacySettled(value, result);
}
value.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); }); value.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); });
value.setWalletErrorCode(null); value.setWalletErrorMessage(null); value.setWalletErrorCode(null); value.setWalletErrorMessage(null);
if ("FREEZE".equals(result.operation()) && Set.of("REJECTED", "CANCELLED").contains(value.getStatus())) { if ("FREEZE".equals(result.operation()) && Set.of("REJECTED", "CANCELLED").contains(value.getStatus())) {
@@ -1319,6 +1313,21 @@ public class RelabelApplicationService implements WalletResultHandler {
value.getWalletErrorMessage(), null); value.getWalletErrorMessage(), null);
} }
/** 旧冻结单仅在实扣成功后入账;已发布历史来源只补扣款引用,避免第二笔应收。 */
private void publishLegacySettled(RelabelApplication app, WalletCommandCompletedV1 result) {
var linked = tailOrders(app);
var order = linked.get(0);
var quantity = BigDecimal.valueOf(Objects.requireNonNullElse(app.getTotalPackageCount(), 0));
var today = LocalDate.now();
var request = new OverseasFeeSourceDtos.SaveRequest(app.getCustomerId(), order.getCustomerName(),
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", app.getId(), app.getRelabelNo(), app.getLastlegOrderId(),
null,null,null,null,null,parseLong(app.getOverseasWarehouseId()),"RELABEL_SERVICE","换标费", null,"RECEIVABLE",
null,null,null,null,null,null,null,List.of(),today,"CNY",app.getChargeAmount(),
quantity.signum()>0?quantity:null,quantity.signum()>0?"BOX":null,null,null,null);
feeSources.completeLegacyRelabelSettlement("WALLET_CALLBACK", "RELABEL:"+app.getRelabelNo(), request,
new com.baiyue.common.event.WalletSettlementReference(result.commandEventId(),result.walletHoldId(),result.walletLedgerId()));
}
private RelabelResponses.Item item(RelabelItem value) { private RelabelResponses.Item item(RelabelItem value) {
return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(), return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(),
value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(), value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(),
@@ -26,12 +26,15 @@ public class ReturnSkuConversionService {
@Resource private ReturnPackingTraceMapper traces; @Resource private ReturnPackingTraceMapper traces;
@Resource private ReturnInventoryOperationMapper operations; @Resource private ReturnInventoryOperationMapper operations;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
/** 换标实作与原商品及计租来源在同一事务转换。 */
@Transactional @Transactional
public void apply(RelabelApplication app, List<RelabelItem> items, List<PdaRelabelUnitFact> facts, String round) { public void apply(RelabelApplication app, List<RelabelItem> items, List<PdaRelabelUnitFact> facts, String round) {
apply(app, items, facts, round, List.of()); apply(app, items, facts, round, List.of());
} }
/** 换标实作与原商品及计租来源在同一事务转换。 */
@Transactional @Transactional
public void apply(RelabelApplication app, List<RelabelItem> items, List<PdaRelabelUnitFact> facts, String round, public void apply(RelabelApplication app, List<RelabelItem> items, List<PdaRelabelUnitFact> facts, String round,
List<PdaRelabelHistory> scans) { List<PdaRelabelHistory> scans) {
@@ -75,6 +78,7 @@ public class ReturnSkuConversionService {
} }
} }
/** 正向转换保存精确原托份额快照,重复作业不追加份额。 */
private void convert(RelabelApplication app, RelabelItem item, ReturnInventoryCarton carton, private void convert(RelabelApplication app, RelabelItem item, ReturnInventoryCarton carton,
String oldSku, String newSku, long quantity, String round, List<ReturnSkuConversion> existing) { String oldSku, String newSku, long quantity, String round, List<ReturnSkuConversion> existing) {
oldSku = code(oldSku); newSku = code(newSku); oldSku = code(oldSku); newSku = code(newSku);
@@ -118,6 +122,8 @@ public class ReturnSkuConversionService {
if (remaining != 0) throw conflict("SKU 换标来源追溯数量不足"); if (remaining != 0) throw conflict("SKU 换标来源追溯数量不足");
try { row.setOwnershipJson(json.writeValueAsString(movedOwners)); } try { row.setOwnershipJson(json.writeValueAsString(movedOwners)); }
catch (java.io.IOException failure) { throw new IllegalStateException("Cannot save SKU conversion ownership", failure); } catch (java.io.IOException failure) { throw new IllegalStateException("Cannot save SKU conversion ownership", failure); }
// 库存与原计租份额同事务转换,保留精确来源以支持审核退回。
row.setStorageRentSharesJson(storageRent.convertReturnSkuShares(carton.getCustomerId(),carton.getOverseasWarehouseId(),carton.getCartonMark(),oldSku,newSku,quantity));
conversions.insert(row); existing.add(row); traceConversion(row, movedOwners, false); conversions.insert(row); existing.add(row); traceConversion(row, movedOwners, false);
} }
@@ -128,6 +134,7 @@ public class ReturnSkuConversionService {
} }
} }
/** 审核退回按原转换快照恢复库存及计租归属。 */
@Transactional @Transactional
public void reverse(RelabelApplication app) { public void reverse(RelabelApplication app) {
var rows = new ArrayList<>(conversions.lockApplication(app.getCustomerId(), app.getId())); Collections.reverse(rows); var rows = new ArrayList<>(conversions.lockApplication(app.getCustomerId(), app.getId())); Collections.reverse(rows);
@@ -152,6 +159,8 @@ public class ReturnSkuConversionService {
target.setQuantity(quantity); target.setPackingReservedQuantity(0L); target.setRelabelledQuantity(0L); contents.insert(target); target.setQuantity(quantity); target.setPackingReservedQuantity(0L); target.setRelabelledQuantity(0L); contents.insert(target);
} else { target.setQuantity(Math.addExact(n(target.getQuantity()), quantity)); contents.updateById(target); } } else { target.setQuantity(Math.addExact(n(target.getQuantity()), quantity)); contents.updateById(target); }
adjustBalances(carton, row.getNewSkuCode(), row.getOldSkuCode(), row.getProductNameZh(), quantity, removed, added); adjustBalances(carton, row.getNewSkuCode(), row.getOldSkuCode(), row.getProductNameZh(), quantity, removed, added);
// 必须恢复原份额,不能将目标 SKU 混合后的计租来源反向估算。
storageRent.reverseReturnSkuShares(carton.getCustomerId(),carton.getOverseasWarehouseId(),carton.getCartonMark(),row.getOldSkuCode(),row.getNewSkuCode(),row.getStorageRentSharesJson());
traceConversion(row, ownership, true); traceConversion(row, ownership, true);
row.setStatus("REVERSED"); row.setReversedAt(LocalDateTime.now()); conversions.updateById(row); refreshTypes(carton); row.setStatus("REVERSED"); row.setReversedAt(LocalDateTime.now()); conversions.updateById(row); refreshTypes(carton);
} }
@@ -22,6 +22,8 @@ public class ReturnSkuConversion {
private Long quantity; private Long quantity;
private String status; private String status;
private String ownershipJson; private String ownershipJson;
/** 原仓租批次迁移快照,审核退回时精确恢复。 */
private String storageRentSharesJson;
private LocalDateTime completedAt; private LocalDateTime completedAt;
private LocalDateTime confirmedAt; private LocalDateTime confirmedAt;
private LocalDateTime reversedAt; private LocalDateTime reversedAt;
@@ -18,6 +18,8 @@ import java.util.*;
/** Box quantities are released together after reconciliation to the immutable pallet receipt totals. */ /** Box quantities are released together after reconciliation to the immutable pallet receipt totals. */
@Service @Service
public class ReturnCartonCountService { public class ReturnCartonCountService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@Resource private ReturnReceiptMapper receipts; @Resource private ReturnReceiptMapper receipts;
@Resource private ReturnOrderMapper orders; @Resource private ReturnOrderMapper orders;
@Resource private ReturnReceiptService returnReceiptService; @Resource private ReturnReceiptService returnReceiptService;
@@ -137,6 +139,8 @@ public class ReturnCartonCountService {
} }
} }
if (!expected.equals(actual)) throw error(HttpStatus.CONFLICT, "各箱 SKU 数量汇总必须与本托签收数量一致;签收=" + expected + ",清点=" + actual); if (!expected.equals(actual)) throw error(HttpStatus.CONFLICT, "各箱 SKU 数量汇总必须与本托签收数量一致;签收=" + expected + ",清点=" + actual);
// 只在原签收和全部待清点箱仍完整时恢复经明确授权的历史计租批次,失败与本次清点一并回滚。
storageRent.ensureReturnCountLot(receipt.getId());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
for (CountBox input : request.boxes()) { for (CountBox input : request.boxes()) {
ReturnInventoryCarton box = byMark.get(input.cartonMark()); items.deleteCarton(box.getId()); ReturnInventoryCarton box = byMark.get(input.cartonMark()); items.deleteCarton(box.getId());
@@ -156,6 +160,7 @@ public class ReturnCartonCountService {
line.setBoxCount(newCounts.getOrDefault(line.getSkuCode(), 0L)); line.setBoxCount(newCounts.getOrDefault(line.getSkuCode(), 0L));
receiptItems.updateById(line); receiptItems.updateById(line);
} }
storageRent.rebuildReturnShares(receipt.getId());
returnReceiptService.refreshCountedTailCargo(receipt); returnReceiptService.refreshCountedTailCargo(receipt);
if (sessions.release(identity.operatorKey(), receipt.getId()) != 1) throw error(HttpStatus.CONFLICT, "清点会话已变化,请重新核对"); if (sessions.release(identity.operatorKey(), receipt.getId()) != 1) throw error(HttpStatus.CONFLICT, "清点会话已变化,请重新核对");
Task result = task(receipt, boxes); Task result = task(receipt, boxes);
@@ -52,6 +52,8 @@ import java.util.HashMap;
/** Return stock keeps its own receipt ownership, carton inventory and putaway facts. */ /** Return stock keeps its own receipt ownership, carton inventory and putaway facts. */
@Service @Service
public class ReturnInventoryService { public class ReturnInventoryService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
public static final String AVAILABLE = "AVAILABLE"; public static final String AVAILABLE = "AVAILABLE";
public static final String MERGED = "MERGED"; public static final String MERGED = "MERGED";
@@ -1176,6 +1178,9 @@ public class ReturnInventoryService {
if (request.height() != null) target.setHeight(request.height()); if (request.height() != null) target.setHeight(request.height());
target.setUpdatedAt(now); target.setLocationCode(null); target.setPutawayAt(null); cartons.updateById(target); cartons.clearPutaway(targetId); target.setUpdatedAt(now); target.setLocationCode(null); target.setPutawayAt(null); cartons.updateById(target); cartons.clearPutaway(targetId);
} }
// 合箱保留每个原签收托的计租份额,不能按目标箱重新起租。
for (TraceTransfer transfer : transfers) storageRent.moveReturnShares(customerId, target.getOverseasWarehouseId(),
transfer.source(), target.getCartonMark(), transfer.sku(), transfer.quantity());
for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(), for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(),
transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now); transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now);
for (ReturnInventoryCarton source : sources) if (completedRelabelSources.contains(source.getId()) && MERGED.equals(source.getStatus()) && source.getRelabelApplicationId() == null) for (ReturnInventoryCarton source : sources) if (completedRelabelSources.contains(source.getId()) && MERGED.equals(source.getStatus()) && source.getRelabelApplicationId() == null)
@@ -57,6 +57,8 @@ import java.util.Set;
/** Customer return forecasts and PDA receipt facts. A receipt batch is the storage-fee start fact. */ /** Customer return forecasts and PDA receipt facts. A receipt batch is the storage-fee start fact. */
@Service @Service
public class ReturnReceiptService { public class ReturnReceiptService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
public static final String PENDING_RECEIPT = "PENDING_RECEIPT"; public static final String PENDING_RECEIPT = "PENDING_RECEIPT";
public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
@@ -709,6 +711,8 @@ public class ReturnReceiptService {
batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo()); batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo());
batch.setOverseasWarehouseId(order.getOverseasWarehouseId()); batch.setStorageBillingStatus("PENDING_PRICING"); batch.setOverseasWarehouseId(order.getOverseasWarehouseId()); batch.setStorageBillingStatus("PENDING_PRICING");
batch.setStorageStartedAt(now); batch.setCreatedAt(now); batches.insert(batch); batch.setStorageStartedAt(now); batch.setCreatedAt(now); batches.insert(batch);
// 签收即启动原托仓租,各分箱共享此原始时间与原托计费基数。
storageRent.registerReturn(order, receipt);
return confirmed(order, receipt, completed); return confirmed(order, receipt, completed);
} }
@@ -78,6 +78,7 @@ public class LastlegOrderService {
@Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private OverseasFeeSourceService feeSources; @Resource private OverseasFeeSourceService feeSources;
@Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees;
@Resource private LastlegOrderOperationLogMapper operationLogMapper; @Resource private LastlegOrderOperationLogMapper operationLogMapper;
@Resource private LastlegOrderManualNodeMapper manualNodeMapper; @Resource private LastlegOrderManualNodeMapper manualNodeMapper;
@Resource private InboundPlanCartonMapper cartonMapper; @Resource private InboundPlanCartonMapper cartonMapper;
@@ -400,6 +401,7 @@ public class LastlegOrderService {
} }
} }
/** 返回订单详情及统一费用审核、余额锁扣状态。 */
public LastlegOrderDetailResponse detail(String customerId, long orderId) { public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId); LastlegOrder order = findOwned(customerId, orderId);
returnOrderReferences.populateForRead(List.of(order)); returnOrderReferences.populateForRead(List.of(order));
@@ -412,7 +414,12 @@ public class LastlegOrderService {
.eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())
.eq(LastlegOrderStatusHistory::getOrderId, orderId) .eq(LastlegOrderStatusHistory::getOrderId, orderId)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); .orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
List<OverseasFeeSource> fees = feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId); List<OverseasFeeSource> orderFees = new java.util.ArrayList<>(
feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId));
// 换标费用以申请为主业务,履约详情同时保留旧订单费用和新申请费用。
if ("RELABEL_APPLICATION".equals(order.getOrderType()))
orderFees.addAll(manualFees.relabelFeesForOrder(order.getCustomerId(), orderId));
List<OverseasFeeSource> fees = manualFees.describe(orderFees);
List<LastlegOrderOperationLog> operations = operationLogMapper.selectList( List<LastlegOrderOperationLog> operations = operationLogMapper.selectList(
new LambdaQueryWrapper<LastlegOrderOperationLog>() new LambdaQueryWrapper<LastlegOrderOperationLog>()
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
@@ -438,22 +445,22 @@ public class LastlegOrderService {
? returnReceipts.detail(order.getCustomerId(), order.getSourceOrderNo()) : null); ? returnReceipts.detail(order.getCustomerId(), order.getSourceOrderNo()) : null);
} }
/** 订单详情统一委托人工费用服务,头程待审核入账,海外业务先锁款。 */
@Transactional @Transactional
public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request, public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
String operatorName, String idempotencyKey) { String operatorName, String idempotencyKey) {
LastlegOrder order = findOwned(customerId, orderId); LastlegOrder order = findOwned(customerId, orderId);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
OverseasFeeSource fee = feeSources.create(operatorName, idempotencyKey, OverseasFeeSource fee = manualFees.create(operatorName, idempotencyKey,
new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(), new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
"OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId, "OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId,
null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()), null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()),
order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(), order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(),
request.feeItemName(), request.direction(), request.supplierId(), request.supplierName(), request.feeItemName(), request.feeRemark(), request.direction(), request.supplierId(), request.supplierName(),
request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(), request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(),
request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(), request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(),
request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(), request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(),
request.unitPrice(), request.periodStart(), request.periodEnd())); request.unitPrice(), request.periodStart(), request.periodEnd()));
fee = feeSources.submit(fee.getSourceFeeId(), operatorName, childKey(idempotencyKey, "submit"));
LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId()); operation.setId(IdWorker.getId());
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.storagerent.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.Pricing;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
/** 仓租价格权威仍为国内仓库服务,不在海外维护第二份客户报价。 */
@FeignClient(name="warehouse-service",contextId="customerStoragePricingClient",url="${baiyue.clients.domestic-warehouse.url:}")
public interface CustomerStoragePricingClient {
/** 使用内部服务令牌读取指定真实库存客户的当前仓租价格。 */
@GetMapping("/internal/warehouse/v1/customer-overseas-storage-fees/{customerId}/pricing")
ApiResponse<Pricing> pricing(@PathVariable("customerId") long customerId,
@RequestHeader("X-Internal-Service-Token") String token);
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.storagerent.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.service.*;
import com.baiyue.lastleg.storagerent.service.preparation.StorageRentPreparationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** PDA 仓租只允许操作本人绑定仓。 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/pda/v1/last-leg/storage-rent")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaStorageRentController {
private final StorageRentService rents;
private final StorageRentPreparationService preparations;
/** 扫码前确认仓租与余额授信冻结结果。 */
@PostMapping("/preparations")
public ApiResponse<PreparationResult> prepare(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody PrepareRequest request) {
return ApiResponse.success(preparations.prepare(PdaWarehouseIdentity.require(user),request));
}
/** 查询已提交预占的真实财务回执状态。 */
@GetMapping("/preparations/{id}")
public ApiResponse<PreparationResult> status(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
return ApiResponse.success(rents.preparation(String.valueOf(PdaWarehouseIdentity.require(user).overseasWarehouseId()),id));
}
/** 放弃扫码释放尚未使用的仓租占用。 */
@DeleteMapping("/preparations/{id}")
public ApiResponse<PreparationResult> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
return ApiResponse.success(rents.cancel(String.valueOf(PdaWarehouseIdentity.require(user).overseasWarehouseId()),id));
}
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.storagerent.controller;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.service.StorageRentService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/** 海外仓租原批次及逐次出库费用查询。 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/storage-rent")
@PreAuthorize("hasAuthority('platform:overseas-fee:list')")
public class PlatformStorageRentController {
private final StorageRentService service;
private final com.baiyue.lastleg.storagerent.service.StorageRentReviewService reviews;
/** 审核前修改仓租金额并强制保存原因,入口权限与其他费用编辑一致。 */
@PostMapping("/fees/{sourceFeeId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<com.baiyue.lastleg.financefee.entity.OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId,@jakarta.validation.Valid @RequestBody AdjustRentRequest request){
platform(user);return ApiResponse.success(reviews.adjust(sourceFeeId,String.valueOf(user.userId()),request));
}
/** 按客户、业务及库存状态分页查询。 */
@GetMapping
public ApiResponse<PageResult<LotView>> list(@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute Query query) {
platform(user);return ApiResponse.success(service.list(query));
}
/** 一个原批次下只列本批实际产生的费用。 */
@GetMapping("/{id}")
public ApiResponse<LotDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
platform(user);return ApiResponse.success(service.detail(id));
}
/** 查询已有计租批次对应仓库。 */
@GetMapping("/warehouse-options")
public ApiResponse<List<WarehouseOption>> warehouses(@AuthenticationPrincipal AuthenticatedUser user) {
platform(user);return ApiResponse.success(service.warehouseOptions());
}
/** 资金与客户信息只允许平台作用域查询。 */
private static void platform(AuthenticatedUser user) {
if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可查询仓租");
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.storagerent.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.List;
/** 仓租列表、来源快照与 PDA 预校验协议。 */
public final class StorageRentDtos {
/** 仅承载协议定义。 */
private StorageRentDtos() { }
public record PrepareRequest(@NotBlank @Pattern(regexp="DISPATCH|OUTBOUND|OUTBOUND_PALLET|DISCARD") String mode,
@NotBlank @Size(max=128) String businessNo, Long itemId, Long positionId, @Positive Long packages,
String cartonMark, String palletNo, @NotBlank @Size(max=255) String idempotencyKey,
List<String> cartonMarks, String skuCode, @PositiveOrZero Long productQuantity,
List<com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos.ActualLine> lines,
List<Long> photoFileIds,List<String> scannedCartonMarks,Boolean confirmAllCartons) {
/** 普通扫码不承载废弃作业的申请明细。 */
public PrepareRequest(String mode,String businessNo,Long itemId,Long positionId,Long packages,
String cartonMark,String palletNo,String idempotencyKey,List<String> cartonMarks,String skuCode,Long productQuantity) {
this(mode,businessNo,itemId,positionId,packages,cartonMark,palletNo,idempotencyKey,cartonMarks,skuCode,productQuantity,null,null,null,null);
}
}
public record PreparationResult(String preparationId, String status, boolean ready, BigDecimal amount,
String currency, BigDecimal originalAmount, String originalCurrency, BigDecimal exchangeRate,
String exchangeRateMonth, String message) { }
public record Query(String customerId, String businessNo, String warehouseId, String businessType,
String status, Long page, Long size) { }
public record WarehouseOption(String id, String warehouseName) { }
public record AdjustRentRequest(@NotNull @Min(1) Integer expectedVersion,
@NotNull @DecimalMin("0.00") @Digits(integer=12,fraction=2) BigDecimal amount,
@NotBlank @Size(max=500) String reason, @NotBlank @Size(max=128) String idempotencyKey) { }
public record Owner(String customerId, String customerName, String businessType, Long businessId,
String businessNo, Long orderId, String inboundCode, String warehouseId) { }
public record Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
LocalDateTime inboundAt, long days) { }
public record PricingTier(String id,Integer startDay,Integer endDay,BigDecimal unitPrice,Integer sortNo,
String feeType,String billingUnit) { }
public record Pricing(String configId,Long customerId,Integer version,String currency,List<PricingTier> tiers,String source) {
/** 固化不可变阶梯快照,防止调用方修改已确认的计费依据。 */
public Pricing { if(tiers!=null)tiers=List.copyOf(tiers); }
}
public record Plan(Owner owner, List<Allocation> allocations, LocalDateTime calculatedAt, BigDecimal amount,Pricing pricing) {
/** 本轮旧测试构造继续明确使用报价表默认值,实际预占必须读取配置服务。 */
public Plan(Owner owner,List<Allocation> allocations,LocalDateTime calculatedAt,BigDecimal amount) {
this(owner,allocations,calculatedAt,amount,com.baiyue.lastleg.storagerent.service.StorageRentCalculator.defaults(owner==null?null:Long.valueOf(owner.customerId())));
}
}
public record CurrencyAmount(String currency,BigDecimal amount) { }
public record LotView(Long id,String customerId,String customerName,String businessType,String businessNo,
String warehouseId,String warehouseName,String batchNo,OffsetDateTime inboundAt,long storageDays,
BigDecimal originalQuantity,BigDecimal remainingQuantity,String quantityUnit,BigDecimal billingQuantity,
String billingUnit,String ruleCode,String status,BigDecimal generatedAmount,String generatedCurrency,List<CurrencyAmount> generatedAmounts,
BigDecimal settledAmount,long chargeCount,long settledChargeCount) { }
public record ChargeView(Long id,String outboundNo,String businessNo,BigDecimal quantity,long storageDays,
BigDecimal originalAmount,String currency,BigDecimal settlementAmount,BigDecimal currencyRate,String status,
OffsetDateTime createdAt,OffsetDateTime outboundAt) { }
public record LotDetail(LotView lot,List<ChargeView> charges) { }
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.storagerent.entity;
import lombok.Data;
/** 当前计费服务单只读投影,仓租不操作其他业务聚合。 */
@Data
public class StorageRentBusinessSource {
private Long id;
private String businessNo;
private Long lastlegOrderId;
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.storagerent.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 实收原批次,库龄及计费基数不可被换标或移库重置。 */
@Data
@TableName("ll_storage_rent_lot")
public class StorageRentLot {
/** 原批次主键。 */
@TableId(type=IdType.INPUT)
private Long id;
/** 实收来源幂等键。 */
private String sourceKey;
/** 客户标识。 */
private String customerId;
/** 客户名称。 */
private String customerName;
/** 海外仓标识。 */
private String warehouseId;
/** 海外仓名称。 */
private String warehouseName;
/** 原入库业务类型。 */
private String businessType;
/** 原入库业务主键。 */
private Long businessId;
/** 原入库业务单号。 */
private String businessNo;
/** 尾程订单主键。 */
private Long orderId;
/** 入仓码。 */
private String inboundCode;
/** 普通库存余额主键。 */
private Long balanceId;
/** 原实收批次。 */
private String batchNo;
/** 商品编码。 */
private String skuCode;
/** 仓租规则。 */
private String ruleCode;
/** 实收原始数量。 */
private BigDecimal originalQuantity;
/** 未出库原始数量。 */
private BigDecimal remainingQuantity;
/** 原批次计费体积或托数。 */
private BigDecimal originalUnits;
/** 实际入库时间。 */
private LocalDateTime inboundAt;
/** 创建时间。 */
private LocalDateTime createdAt;
/** 更新时间。 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.storagerent.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 一次扫码的资金冻结与不可变仓租快照。 */
@Data
@TableName("ll_storage_rent_preparation")
public class StorageRentPreparation {
/** 预校验主键。 */
@TableId(type=IdType.INPUT)
private Long id;
/** 作业海外仓。 */
private String warehouseId;
/** 费用客户。 */
private String customerId;
/** 扫码类型。 */
private String mode;
/** 实际作业单号。 */
private String executionNo;
/** 原扫码幂等键。 */
private String requestKey;
/** 原扫码内容哈希。 */
private String requestHash;
/** 冻结尝试次数。 */
private Integer attempt;
/** 本次应收业务类型。 */
private String businessType;
/** 本次应收业务主键。 */
private Long businessId;
/** 本次应收业务单号。 */
private String businessNo;
/** 资金与扫码状态。 */
private String status;
/** 原批次计费不可变快照。 */
private String planJson;
/** 美元仓租金额。 */
private BigDecimal originalAmount;
/** 人民币结算金额。 */
private BigDecimal amount;
/** 冻结确认汇率。 */
private BigDecimal exchangeRate;
/** 汇率月份。 */
private String exchangeRateMonth;
/** 冻结指令事件。 */
private String freezeEventId;
/** 扣款指令事件。 */
private String settleEventId;
/** 解冻指令事件。 */
private String releaseEventId;
/** 解冻回执状态,失败后由恢复任务重发。 */
private String releaseStatus;
/** 同一预占资金命令版本,重试使用新版本保留审计。 */
private Long walletVersion;
/** 财务冻结主键。 */
private String holdId;
/** 财务扣款流水。 */
private String ledgerId;
/** 资金处理结果。 */
@TableField(updateStrategy=FieldStrategy.ALWAYS)
private String errorMessage;
/** 财务费用来源主键。 */
private Long sourceFeeId;
/** 预校验时间。 */
private LocalDateTime createdAt;
/** 实际扫描出库时间。 */
private LocalDateTime consumedAt;
/** 更新时间。 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.storagerent.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 退货箱内商品与原计租批次的数量归属。 */
@Data
@TableName("ll_storage_rent_share")
public class StorageRentShare {
/** 普通库存当前余额,退货箱份额为空。 */
private Long balanceId;
/** 普通库存当前批次,换标搬移保持原计租批次引用。 */
private String batchNo;
/** 份额主键。 */
@TableId(type=IdType.INPUT)
private Long id;
/** 原计租批次主键。 */
private Long lotId;
/** 客户标识。 */
private String customerId;
/** 海外仓标识。 */
private String warehouseId;
/** 当前箱唛。 */
private String cartonMark;
/** 商品编码。 */
private String skuCode;
/** 剩余商品数量。 */
private BigDecimal quantity;
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.storagerent.gateway;
import com.baiyue.lastleg.storagerent.client.CustomerStoragePricingClient;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** 无配置时只接受权威服务明确返回的默认报价,网络或非法配置不能降级免租。 */
@Component
public class StorageRentPricingGateway {
@Resource private CustomerStoragePricingClient client;
@Value("${baiyue.security.internal-service-token}") private String token;
/** 不缓存客户报价;每次新预占读取当前版本,消费只用已保存的快照。 */
public Pricing current(String customer) {
try {
long id=Long.parseLong(customer);var response=client.pricing(id,token);
if(response==null||!response.successful()||response.data()==null)throw invalid();
var pricing=response.data();validate(pricing,id);return pricing;
} catch(ResponseStatusException failure){throw failure;}
catch(Exception failure){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"客户仓租配置暂不可用,不能出库",failure);}
}
/** 保存和重放都校验完整阶梯,避免缺档、错单位或混币产生低收费。 */
public static void validate(Pricing pricing,long customer) {
if(pricing==null||!Objects.equals(pricing.customerId(),customer)||!("USD".equals(pricing.currency())||"CNY".equals(pricing.currency()))
||!("CUSTOMER".equals(pricing.source())||"DEFAULT".equals(pricing.source()))||pricing.tiers()==null||pricing.tiers().isEmpty()
||pricing.version()!=null&&pricing.version()<0||"CUSTOMER".equals(pricing.source())&&(pricing.version()==null||pricing.configId()==null||pricing.configId().isBlank()))throw invalid();
for(var tier:pricing.tiers())if(tier==null||tier.startDay()==null||tier.startDay()<1||tier.unitPrice()==null||tier.unitPrice().signum()<0
||!("STORAGE".equals(tier.feeType())&&"CBM".equals(tier.billingUnit())||"RETURN_STORAGE".equals(tier.feeType())&&"PALLET".equals(tier.billingUnit())))throw invalid();
for(String type:List.of("STORAGE","RETURN_STORAGE")) {
var tiers=pricing.tiers().stream().filter(t->type.equals(t.feeType())).sorted(Comparator.comparing(PricingTier::startDay)).toList();
if(tiers.isEmpty())throw invalid();long next=1;
for(int i=0;i<tiers.size();i++){var t=tiers.get(i);if(t.startDay()!=next||t.endDay()!=null&&t.endDay()<t.startDay())throw invalid();
if(t.endDay()==null){if(i!=tiers.size()-1)throw invalid();next=-1;}else next=(long)t.endDay()+1;}
if(next!=-1)throw invalid();
}
}
/** 配置异常是出库门禁,绝不返回空阶梯或零金额替代。 */
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.CONFLICT,"客户仓租配置不完整,不能出库");}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.storagerent.mapper;
import com.baiyue.lastleg.storagerent.entity.StorageRentBusinessSource;
import org.apache.ibatis.annotations.*;
/** 同服务本地只读业务归属投影,不借用其他聚合的写 Mapper。 */
@Mapper
public interface StorageRentBusinessSourceMapper {
/** 换标出库号解析该次独立服务单。 */
@Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND outbound_order_no=#{outbound} LIMIT 1")
StorageRentBusinessSource relabel(@Param("customer") String customer,@Param("outbound") String outbound);
/** 排车来源换标单号精确匹配当前客户。 */
@Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND relabel_no=#{number} LIMIT 1")
StorageRentBusinessSource relabelNo(@Param("customer") String customer,@Param("number") String number);
/** 仓储转运独立业务单归属。 */
@Select("SELECT id,transfer_no AS business_no,lastleg_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} LIMIT 1")
StorageRentBusinessSource transfer(@Param("customer") String customer,@Param("number") String number);
/** 原尾程投影映射唯一仓储转运业务。 */
@Select("SELECT id,transfer_no AS business_no,lastleg_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND lastleg_order_id=#{id} LIMIT 1")
StorageRentBusinessSource transferByTail(@Param("customer") String customer,@Param("id") Long id);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.storagerent.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.storagerent.entity.StorageRentLot;
/** 实收原批次,库龄及计费基数不可被换标或移库重置持久化。 */
@Mapper
public interface StorageRentLotMapper extends BaseMapper<StorageRentLot> {
/** 当前读实收计租登记,补入库及出库后重放均不重复累计。 */
@org.apache.ibatis.annotations.Select("SELECT lot_id FROM ll_storage_rent_inbound WHERE receipt_id=#{receiptId} FOR UPDATE")
Long inboundRentLot(@org.apache.ibatis.annotations.Param("receiptId") long receiptId);
/** 同一实收凭证仅登记一次,异常回滚同时撤销批次和份额增量。 */
@org.apache.ibatis.annotations.Insert("INSERT INTO ll_storage_rent_inbound(receipt_id,lot_id,quantity,created_at) VALUES(#{receiptId},#{lotId},#{quantity},UTC_TIMESTAMP(3))")
int recordInboundRent(@org.apache.ibatis.annotations.Param("receiptId") long receiptId,@org.apache.ibatis.annotations.Param("lotId") long lotId,@org.apache.ibatis.annotations.Param("quantity") java.math.BigDecimal quantity);
/** 仓库名称是原业务引用快照,选项不构建独立仓库主档。 */
@org.apache.ibatis.annotations.Select("SELECT warehouse_id AS id,MAX(warehouse_name) AS warehouse_name FROM ll_storage_rent_lot GROUP BY warehouse_id ORDER BY warehouse_name,warehouse_id")
java.util.List<com.baiyue.lastleg.storagerent.dto.StorageRentDtos.WarehouseOption> warehouseOptions();
}

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