Compare commits

..
261 changed files with 926 additions and 10923 deletions
@@ -1,13 +1,4 @@
package com.baiyue.common.event;
/** 成功扣款凭据;资金业务身份独立于费用对应的实际服务订单。 */
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);
}
}
/** Finance-issued identity of a successful consumption, carried by its immutable fee source. */
public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId) { }
@@ -1,11 +1,4 @@
/**
* 国内与海外业务单元共享的稳定事件契约。
*
* <p>海外费用的 walletSettlement 引用成功扣款的 commandEventId、holdId、ledgerId。
* 独立资金流程另外携带 walletBusinessType、walletBusinessNo:人工费用为
* OVERSEAS_MANUAL_FEE / 来源费用ID-冻结次数,仓租为 STORAGE_RENT / 出库预占ID。
* 外层 businessType、businessNo 始终表示实际服务订单,不能替换为资金流程编号。
* Finance 同时核对冻结、实扣流水、成功指令结果及客户金额,一个 hold 只能关联一个费用来源。
* 资金身份与服务订单一致的既有引用省略这两个字段,保持既有快照哈希。</p>
*/
package com.baiyue.common.event;
@@ -1,22 +0,0 @@
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);
}
}
@@ -1,39 +0,0 @@
# 海外费用与仓租数据库定义汇总
本次功能的数据库定义已统一到下面两份 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)。
+2 -18
View File
@@ -1,19 +1,9 @@
# 单测试环境数据库空库初始化
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
`finance-overseas-fee-hash-width-repair.sql` 用于明确授权的本机开发环境海外费用推送修复,固定目标 `baiyue_finance`。先核对 `fin_inbox_event.payload_hash`、`fin_fee_source.source_hash` 两列;仅将不足71位的长度扩至71位,兼容 `sha256:` 前缀,保留空值约束和字符集排序规则,不更新业务数据。缺列、异常类型或小于64位时停止;已有足够长度则跳过。使用独立连接、遇错停止,执行后回查结构,再通过原费用事件重试验证,禁止重新生成费用或扣款。脚本包含执行后移除的专用校验过程;创建或执行失败须停止,不扩大修复范围。文件准备不表示已执行。
`warehouse-supplier-query-structure-repair.sql` 用于明确授权的本机开发环境供应商查询结构修复,固定目标 `baiyue_warehouse`:仅在缺失时新增 `wh_supplier.trucking_scope`,并按当前模块基线创建缺失的 `wh_supplier_carrier` 空表。结构存在则跳过,不更新供应商类别、费用或资金,不推断历史拖车的国内/海外归属。使用独立连接、遇错停止,执行后回查结构并通过正式供应商列表 API 验证;不需要重启服务。文件准备不表示已获授权或已执行。
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
@@ -79,7 +69,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 238 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -102,7 +92,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/238`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -202,9 +192,3 @@ 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、回填或删除测试存量。
海外费用与仓租首版统一维护在 `头程数据库统一增量-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`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
@@ -1,75 +0,0 @@
-- 本机开发环境海外费用推送结构修复。固定目标: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'));
@@ -1,16 +0,0 @@
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
@@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
@@ -1,9 +0,0 @@
-- 先于新版本 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;
@@ -1,121 +0,0 @@
-- 海外费用既有写按钮最小修复;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
@@ -1,19 +0,0 @@
-- 海外人工费用说明字段。仅修改 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';
@@ -1,73 +0,0 @@
-- 固定目标本机 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;
@@ -1,40 +0,0 @@
-- 供应商查询缺失结构修复。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,5 +1,4 @@
-- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
-- 海外费用菜单及平台管理员授权统一在本文件 IAM 种子中维护,不再单列本次版本或专项菜单脚本。
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
@@ -8459,8 +8458,4 @@ CREATE TABLE wh_document_sequence (
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
) 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;
+5 -138
View File
@@ -1,5 +1,4 @@
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
-- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
@@ -315,7 +314,7 @@ CREATE TABLE ll_destuff_pallet_cost_allocation (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收体积',
allocated_amount decimal(20,2) NOT NULL COMMENT '客户分摊金额',
fee_source_id bigint DEFAULT NULL COMMENT '人工审核后关联的海外费用来源ID',
fee_source_id bigint NOT NULL COMMENT '海外费用来源ID',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_destuff_customer (cost_id,customer_id),
@@ -948,7 +947,6 @@ CREATE TABLE ll_finance_fee_source (
warehouse_id bigint DEFAULT NULL COMMENT '关联海外仓ID',
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_remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用说明,补收或重复作业原因',
direction varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
supplier_id bigint DEFAULT NULL COMMENT '应付供应商ID',
supplier_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照',
@@ -964,7 +962,7 @@ CREATE TABLE ll_finance_fee_source (
amount decimal(20,6) NOT NULL COMMENT '人民币结算金额',
original_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务原币种,支持CNY或USD',
original_amount decimal(20,6) NOT NULL COMMENT '业务原币金额',
exchange_rate decimal(20,6) DEFAULT NULL COMMENT '原币折算人民币汇率,人民币固定为1,自动零元仓租可空',
exchange_rate decimal(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1',
exchange_rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份yyyy-MM',
chargeable_quantity decimal(20,6) DEFAULT NULL COMMENT '计费数量,如箱数、托数、体积或天数',
quantity_unit varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费数量单位',
@@ -976,7 +974,6 @@ CREATE TABLE ll_finance_fee_source (
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 '审核或作废原因',
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 '审核时间',
created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID',
updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID',
@@ -987,8 +984,7 @@ CREATE TABLE ll_finance_fee_source (
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_container_customer (container_booking_id,customer_id,source_status,created_at),
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_amount CHECK ((amount > 0)),
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_original_currency CHECK ((original_currency in (_utf8mb4'CNY',_utf8mb4'USD'))),
@@ -2126,7 +2122,6 @@ CREATE TABLE ll_overseas_procurement_file (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证';
CREATE TABLE ll_overseas_procurement_order (
procurement_direction VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS_TO_DOMESTIC' COMMENT '采购方向:海外采购回国内/国内采购发海外',
id bigint 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 '客户名称快照',
@@ -3208,7 +3203,7 @@ CREATE TABLE ll_fleet_external_booking (
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号',
incoming_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '正式来货单业务编号',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
@@ -4598,7 +4593,6 @@ CREATE TABLE ll_return_sku_conversion (
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照',
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
@@ -4993,131 +4987,4 @@ CREATE TABLE ll_document_sequence (
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
) 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 (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 240 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
@@ -7,8 +7,6 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory {
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -92,7 +90,7 @@ enum BusinessFileCategory {
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
}
if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
}
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
@@ -1,88 +0,0 @@
openapi: 3.0.3
info:
title: 车辆与司机档案照片内部文件契约
version: 1.0.0
description: >-
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_VEHICLE_PHOTO或FLEET_DRIVER_PHOTO、
businessId=对应车辆或司机内部ID。Lastleg负责档案存在性、列表/维护权限、版本关联和历史可见性;
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅JPG/PNG。
上传只生成不可变对象,不改变驾驶证审核结论,不生成公开URL,排车保存时复制为本趟证据,后续档案替换不改历史排车。
security:
- InternalServiceToken: []
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
post:
summary: 上传车辆或司机档案照片
parameters:
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
'200': {$ref: '#/components/responses/Metadata'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
summary: 校验归属并读取元数据
responses:
'200': {$ref: '#/components/responses/Metadata'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
summary: 校验归属并下载真实文件
responses:
'200':
description: 文件内容,业务错误按JSON code识别
content:
image/jpeg: {schema: {type: string, format: binary}}
image/png: {schema: {type: string, format: binary}}
'403': {description: 内部服务令牌无效}
components:
securitySchemes:
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
parameters:
TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}}
BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}}
BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
Metadata:
description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string, nullable: true}
data:
type: object
properties:
fileId: {type: string}
tenantId: {type: string, enum: ['0']}
businessType: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}
businessId: {type: string}
fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [image/jpeg, image/png]}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
@@ -1,16 +0,0 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class FleetResourcePhotoCategoryTest {
@Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() {
for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) {
category.validateOwner(0);
assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台");
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024);
assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class);
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
| 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
| 客户资金账户、授信与充值 | `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/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放,第三方支付回调尚未接入 |
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
@@ -7,16 +7,11 @@ import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest;
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.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/finance/v1")
@@ -67,25 +62,4 @@ public class PlatformFinanceWalletController {
@GetMapping("/recharge-requests/{requestId}/voucher")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
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,9 +54,6 @@ public final class FinanceWalletDtos {
public record WalletHoldActionRequest(String remark) { }
/** 关联原消费退款,原因必填,原流水保持不可变。 */
public record WalletRefundRequest(BigDecimal amount, String reason) { }
public record WalletHold(long holdId, long customerId, String businessType,
String businessNo, BigDecimal amount,
BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount,
@@ -135,7 +135,6 @@ public class BusinessFeeSourceEventService {
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
walletIdentity(value);
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
}
@@ -155,7 +154,7 @@ public class BusinessFeeSourceEventService {
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
validateSettlement(value);
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|| value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null
|| !blank(value.settlementMode()) || value.paidAt() != null
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实");
@@ -203,10 +202,9 @@ public class BusinessFeeSourceEventService {
long customerId = positive(source.customerId(), "customerId");
long holdId = positive(reference.holdId(), "holdId");
long ledgerId = positive(reference.ledgerId(), "ledgerId");
String[] identity = walletIdentity(source);
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",
reference.commandEventId(), holdId, ledgerId, customerId, identity[0], identity[1]);
reference.commandEventId(), holdId, ledgerId, customerId, source.businessType(), source.businessNo());
if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount)
|| amount.negate().compareTo(source.totalAmount()) != 0) {
throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配");
@@ -220,8 +218,8 @@ public class BusinessFeeSourceEventService {
|| !reference.holdId().equals(result.path("walletHoldId").asText())
|| !reference.ledgerId().equals(result.path("walletLedgerId").asText())
|| result.path("customerId").asLong() != customerId
|| !identity[0].equals(result.path("businessType").asText())
|| !identity[1].equals(result.path("businessNo").asText())
|| !source.businessType().equals(result.path("businessType").asText())
|| !source.businessNo().equals(result.path("businessNo").asText())
|| result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) {
throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配");
}
@@ -236,25 +234,6 @@ public class BusinessFeeSourceEventService {
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) {
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(),
@@ -1,61 +0,0 @@
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,82 +286,4 @@ class BusinessFeeSourceEventServiceTest {
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
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)));
}
}
@@ -1,54 +0,0 @@
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"));
}
}
@@ -1,183 +0,0 @@
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);
}
}
@@ -1,58 +0,0 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.SharedFeeCatalogService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** 公共费用目录内部只读入口,禁止借用平台员工令牌跨服务查询。 */
@RestController
@RequestMapping("/internal/firstleg/v1/fee-items")
public class InternalFeeCatalogController {
private final SharedFeeCatalogService service;
private final byte[] expectedToken;
/** 绑定费用目录服务与内部身份凭证,空凭证始终拒绝访问。 */
public InternalFeeCatalogController(SharedFeeCatalogService service,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.service = service;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
/** 返回适用海外仓收付方向的启用项目。 */
@GetMapping
public ApiResponse<List<SharedFeeCatalogService.Item>> list(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam(defaultValue = "RECEIVABLE") String direction) {
requireToken(token);
return ApiResponse.success(service.list(direction));
}
/** 在费用创建时验证实时项目资格。 */
@GetMapping("/{code}")
public ApiResponse<SharedFeeCatalogService.Item> detail(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable String code, @RequestParam String direction) {
requireToken(token);
return ApiResponse.success(service.requireActive(code, direction));
}
/** 使用常量时间比较内部凭证,未配置凭证时关闭入口。 */
private void requireToken(String token) {
if (expectedToken.length == 0 || token == null
|| !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -383,12 +383,11 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getConfirmedAt, now));
}
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus,
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
@@ -54,18 +54,6 @@ public class FeeItemRepository {
.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) {
return feeItems.selectCount(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID)
@@ -1,53 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
/** 向海外业务提供公共费用目录的只读切片,项目维护仍归头程主数据。 */
@Service
@RequiredArgsConstructor
public class SharedFeeCatalogService {
private final FeeItemRepository repository;
/** 按收付方向返回当前可使用的费用项目。 */
public List<Item> list(String direction) {
requireDirection(direction);
return repository.listActiveInternationalWarehouseItems(direction).stream()
.map(SharedFeeCatalogService::snapshot).toList();
}
/** 写入业务费用前重新验证项目资格,停用或方向变化不得沿用旧选项。 */
public Item requireActive(String code, String direction) {
requireDirection(direction);
FeeItemEntity item = repository.findItemByCode(code);
if (item == null || !"ACTIVE".equals(item.getStatus())
|| !"INTERNATIONAL".equals(item.getTradeScope())
|| !("RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getWarehouseReceivable())
: Boolean.TRUE.equals(item.getWarehousePayable()))) {
throw new BizException(ResultCode.CONFLICT, "费用项目未启用或不适用于本次收费");
}
return snapshot(item);
}
/** 校验显式收付方向,防止空方向绕过目录过滤。 */
private static void requireDirection(String direction) {
if (!"RECEIVABLE".equals(direction) && !"PAYABLE".equals(direction)) {
throw new BizException(ResultCode.BAD_REQUEST, "请选择费用收付方向");
}
}
/** 返回有限字段,不向其他服务暴露目录实体或审计属性。 */
private static Item snapshot(FeeItemEntity item) {
return new Item(item.getFeeCode(), item.getFeeTypeName(), item.getDefaultPrice());
}
public record Item(String code, String name, BigDecimal defaultPrice) { }
}
@@ -9,12 +9,11 @@ import java.util.List;
@Mapper
public interface ContainerCalendarSourceMapper {
/** 按明确来源柜读取到港事实;目的港优先显示名称,未填名称时显示已保存代码。 */
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
@Select("""
<script>
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
@@ -29,11 +28,10 @@ public interface ContainerCalendarSourceMapper {
""")
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
/** 选出到港、缺日期和可能还柜的有效出仓柜;目的港名称为空时展示代码,最终日期由服务复核。 */
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
@Select("""
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
@@ -40,7 +40,7 @@ paths:
containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true, description: 目的港展示值,优先名称,名称为空时使用已保存的港口代码,两者均为空时返回null}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
arrivalAt: {type: string, format: date-time, nullable: true}
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
@@ -1,24 +0,0 @@
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 }
@@ -1,107 +0,0 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.firstleg.mapper.InboundPricingMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 通过真实 Mapper 执行费用状态更新,防止业务层放行但持久化条件遗漏作业状态。 */
class FbaPricingFeeTransitionTest {
private SqlSession session;
private JdbcTemplate sql;
private FbaPricingRepository repository;
/** 每项测试使用独立内存表,保留真实的客户和逻辑删除条件。 */
@BeforeEach
void setUp() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:fee-transition-" + UUID.randomUUID() + ";MODE=MySQL");
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(InboundPricingMapper.class);
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
session.getConnection();
sql = new JdbcTemplate(ds);
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
repository = new FbaPricingRepository();
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
}
/** 关闭最后一个连接后销毁本项测试的内存数据库。 */
@AfterEach
void tearDown() {
if (session != null) session.close();
}
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
@ParameterizedTest
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
insertInbound(processStatus, "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
assertState(processStatus, "CONFIRMED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
}
/** 待组托只补费用确认资格,不能绕过费用快照的前置状态。 */
@ParameterizedTest
@ValueSource(strings = {"UNPRICED", "CONFIRMED", "SENT", "VOIDED"})
void rejectsConfirmationUnlessFeeIsCalculated(String feeStatus) {
insertInbound("WAIT_PALLETIZE", feeStatus);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertState("WAIT_PALLETIZE", feeStatus);
}
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
@Test
void preservesProcessCustomerAndDeletionGuards() {
insertInbound("WAIT_MEASURE", "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertState("WAIT_PALLETIZE", "CALCULATED");
}
/** 模拟发送前作业状态发生变化,旧状态条件须拒绝写入。 */
@Test
void rejectsSendWithStaleProcessStatus() {
insertInbound("WAIT_SPLIT", "CONFIRMED");
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_PALLETIZE")).isZero();
assertState("WAIT_SPLIT", "CONFIRMED");
}
/** 准备单个客户的入库单,不调用共享环境。 */
private void insertInbound(String processStatus, String feeStatus) {
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
processStatus, feeStatus);
}
/** 同时核对物理作业状态与费用状态,防止用改主流程掩盖发送问题。 */
private void assertState(String processStatus, String feeStatus) {
assertThat(sql.queryForObject("SELECT process_status FROM fl_inbound_order WHERE id=88", String.class))
.isEqualTo(processStatus);
assertThat(sql.queryForObject("SELECT fee_status FROM fl_inbound_order WHERE id=88", String.class))
.isEqualTo(feeStatus);
}
}
@@ -194,9 +194,8 @@ class FbaPricingRepositoryRuleListTest {
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
}
/** 费用确认状态集覆盖待组托及后续履约阶段。 */
@Test
void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
void feeConfirmationAllowsPostLoadingInboundStatuses() {
String source;
try {
source = Files.readString(Path.of(
@@ -206,7 +205,7 @@ class FbaPricingRepositoryRuleListTest {
throw new AssertionError(exception);
}
assertThat(source)
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
}
@Test
@@ -22,9 +22,6 @@ import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.R
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.annotations.Select;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -226,13 +223,11 @@ class FbaPricingServiceTest {
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
/** 待组托、待分柜确认费用均不推进物流主流程。 */
@ParameterizedTest
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
@DisplayName("待组托或待分柜确认费用后仍保持主流程状态")
void confirmsWithoutRollingBackProcessStatus(String processStatus) {
@Test
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus(processStatus);
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
@@ -249,7 +244,7 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
assertThat(response.nextProcessStatus()).isEqualTo(processStatus);
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@@ -441,13 +436,11 @@ class FbaPricingServiceTest {
verify(repository, never()).insertFee(any());
}
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
@ParameterizedTest
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@Test
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
void sendIsIdempotentAndDoesNotDuplicateOutbox() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus(processStatus);
inbound.setProcessStatus("FEE_CONFIRMING");
inbound.setFeeStatus("CONFIRMED");
InboundFeeEntity confirmed = savedFee("CONFIRMED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -455,7 +448,7 @@ class FbaPricingServiceTest {
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
.thenReturn(null, completed("FEE_SEND", 501L));
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
when(repository.markInboundFeeSent(TENANT, INBOUND, processStatus)).thenReturn(1);
when(repository.markInboundFeeSent(TENANT, INBOUND, "FEE_CONFIRMING")).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
@@ -466,8 +459,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once");
FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once");
assertThat(first.nextProcessStatus()).isEqualTo(expectedStatus);
assertThat(repeated.nextProcessStatus()).isEqualTo(expectedStatus);
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET");
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET");
verify(financeOutbox, times(1)).publishInbound(
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
eq(501L), eq(1), eq(NOW));
@@ -36,9 +36,8 @@ class MerchantInboundBillingServiceTest {
@MockBean private InboundFeeDetailService feeDetails;
@Resource private MerchantInboundBillingService service;
/** 测量审核通过并发送费用后,待组托与后续履约阶段均可查看客户费用。 */
@ParameterizedTest
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
@ValueSource(strings = {"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) {
InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT");
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
@@ -85,11 +84,9 @@ class MerchantInboundBillingServiceTest {
verify(orders).findByOrderId(10001L, 77L);
}
/** 待组托不绕过客户费用可见性的测量审核条件。 */
@ParameterizedTest
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
void shouldHandleCase02(String processStatus) {
InboundOrderEntity inbound = inbound(processStatus, "PENDING", "SENT");
@Test
void shouldHandleCase02() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "PENDING", "SENT");
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
assertThatThrownBy(() -> service.find(10001L, 77L))
@@ -108,19 +105,6 @@ class MerchantInboundBillingServiceTest {
verify(pricing, never()).latest(20002L, 88L);
}
/** 客户可在组托前确认已审核并发送的费用,继续使用当前费用版本和幂等键。 */
@Test
void confirmsPublishedFeeWhileWaitingForPalletization() {
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound("WAIT_PALLETIZE", "APPROVED", "SENT"));
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
when(pricing.confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize"))
.thenReturn(fee);
assertThat(service.confirmFee(10001L, 7L, "客户成员", 77L, 3, "confirm-palletize"))
.isSameAs(fee);
verify(pricing).confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize");
}
@Test
void shouldHandleCase04() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT");
@@ -1,45 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.controller.InternalFeeCatalogController;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SharedFeeCatalogServiceTest {
/** 已停用、方向变更或非海外项目不能沿用页面旧选项。 */
@Test void currentEligibilityIsCheckedBeforeUse() {
FeeItemRepository repository = mock(FeeItemRepository.class);
SharedFeeCatalogService service = new SharedFeeCatalogService(repository);
FeeItemEntity item = new FeeItemEntity();
item.setFeeCode("FEE-1"); item.setFeeTypeName("操作费");
item.setStatus("ACTIVE"); item.setTradeScope("INTERNATIONAL");
item.setWarehouseReceivable(true); item.setDefaultPrice(new BigDecimal("2.50"));
when(repository.findItemByCode("FEE-1")).thenReturn(item);
when(repository.listActiveInternationalWarehouseItems("RECEIVABLE")).thenReturn(List.of(item));
assertEquals(new BigDecimal("2.50"), service.list("RECEIVABLE").get(0).defaultPrice());
assertEquals("操作费", service.requireActive("FEE-1", "RECEIVABLE").name());
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "PAYABLE"));
item.setStatus("INACTIVE");
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "RECEIVABLE"));
item.setStatus("ACTIVE"); item.setTradeScope("DOMESTIC");
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "RECEIVABLE"));
assertThrows(BizException.class, () -> service.list(null));
}
/** 内部入口未配置、缺少或错误凭证均不得读取目录。 */
@Test void internalCatalogRequiresExplicitServiceIdentity() {
SharedFeeCatalogService service = mock(SharedFeeCatalogService.class);
InternalFeeCatalogController controller = new InternalFeeCatalogController(service, "configured-token");
assertThrows(BizException.class, () -> controller.list(null, "RECEIVABLE"));
assertThrows(BizException.class, () -> controller.detail("wrong", "FEE-1", "RECEIVABLE"));
assertThrows(BizException.class, () -> new InternalFeeCatalogController(service, "").list("", "RECEIVABLE"));
verifyNoInteractions(service);
when(service.list("RECEIVABLE")).thenReturn(List.of());
assertTrue(controller.list("configured-token", "RECEIVABLE").successful());
}
}
@@ -11,41 +11,6 @@ import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class ContainerCalendarSourceMapperTest {
/** 两个日历来源入口均优先展示港口名称,仅填代码时仍可回显,缺失时保持空值。 */
@Test
void displaysDestinationNameOrSavedCodeAcrossBothEntrypoints() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:calendar-port-display;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
sql.execute("INSERT INTO fl_container_booking(id,destination_port_name,destination_port_code,record_status) VALUES " +
"(1,'洛杉矶','USLAX','ACTIVE'),(2,NULL,'USLAX','ACTIVE'),(3,' ','USLAX','ACTIVE')," +
"(4,'洛杉矶',NULL,'ACTIVE'),(5,'',' ','ACTIVE')");
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(ContainerCalendarSourceMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var mapper = session.getMapper(ContainerCalendarSourceMapper.class);
var from = LocalDateTime.parse("2026-10-01T00:00:00");
var calendar = mapper.sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
var explicit = mapper.byIds(java.util.List.of(1L, 2L, 3L, 4L, 5L));
var expected = java.util.Arrays.asList("洛杉矶", "USLAX", "USLAX", "洛杉矶", null);
assertEquals(expected, calendar.stream().map(row -> row.destinationPortName()).toList());
for (var row : explicit) {
assertEquals(expected.get(row.containerBookingId().intValue() - 1), row.destinationPortName());
}
assertEquals(5, explicit.size());
}
}
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
@Test
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
@@ -53,7 +18,7 @@ class ContainerCalendarSourceMapperTest {
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
@@ -100,7 +65,7 @@ class ContainerCalendarSourceMapperTest {
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
@@ -1,50 +0,0 @@
-- 海外费用查询菜单;编辑与审核沿用独立财务按钮权限,只读授权不包含写操作。
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;
+3 -13
View File
@@ -36,7 +36,6 @@
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
@@ -58,9 +57,8 @@
| 派送三类资料、摘要与历史证据 | `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` 在同一事务完成 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `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 自建余额账本 |
| 海外业务费用来源 | `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` |
| 海外业务资金指令与结果 | `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 尚须以实际代码落地验证,不能因文档存在宣称已实现 |
| 美国/本土卡车订单 | `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` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `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`,车辆档案不含派送履约历史 |
@@ -88,7 +86,7 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五类装车照片(每类最多5张),无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -163,11 +161,3 @@
- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
- 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。
### 海外人工收费首版切换
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购保留独立收费流程,禁止进入人工通用收费。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
@@ -264,7 +264,3 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。
### 档案照片只读复用边界(2026-10-26)
`DispatchMasterPhotoService`仅通过`fleet.photo.port.FleetResourcePhotoPort`及其照片DTO读取档案照片,再经排车文件网关生成当趟副本。`TransportTaskService`仅新增照片类别DTO引用,不读取照片Mapper或Repository。`fleet.photo`复用车辆`VehicleRepository`应用端口与司机应用服务校验身份,不反向依赖排车;精确边界纳入依赖合同。
@@ -1,167 +0,0 @@
# 海外费用与仓租首版发布验收
本文件记录本次实现范围及验收口径。源码、本地测试、模拟页面预览、环境部署与实际资金验收分别记录;本地结构补充与重启见文末记录,不能据此认定上线验收通过。
## 首版规则
费用入口为 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 锁回滚和未知网络回执;页面视觉验收也仍需人工截图确认。
@@ -1,12 +0,0 @@
# 采购双方向履约
2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。
- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;平台审核仅展示客户下单方向,不允许审核改向;采购仓按该方向筛选。审核底部直接取消、拒绝或通过,拒绝必须填写具体原因。
- 起运收货仓按方向读取 Warehouse 权威公共启用仓目录,国内方向只接受 DOMESTIC,进口方向保持海外 RECEIVING 能力约束。实际收货、计划仓更改、实际仓更正及面单寄件地址均校验同一方向。
- 出口流程:审核 → 国内购买 → 国内分次实收并确认 → 装箱 → 国际面单、预估运费冻结、贴单 → 国内国际交寄 → 海外分次实收并确认 → 海外核定实际费用 → 寄海外客户。正常终态仍为 SHIPPED_TO_CUSTOMER,取消、拒绝、异常关闭规则保留。
- 此次沿用单商品、一次购买、两段各整批交寄及 Admin 台账,不新增 WMS 库存、PDA 入账、分批购买或分批交寄。目的地接收地址沿用国际面单地址快照,最终客户地址由下单提供,最终面单变更仍需客户凭证。
- 为保护历史数据关联,技术编码 OVERSEAS/DOMESTIC、overseas_received_at/domestic_received_at 及既有状态编码分别表示起运段/目的地段;业务展示按方向转换国内、海外。权限编码、费用用途 DOMESTIC_FREIGHT 和资金引用保持兼容;费用名称按实际目的地区域保存。
- 版本与幂等覆盖方向;未传新字段的旧请求序列化忽略空方向,保留旧哈希。上线先执行 V34,再发布 Lastleg、Admin、Client;不能仅发布前端。
自动验证覆盖方向持久化与幂等、国内仓主档校验、两段出口地址与交寄终态,以及页面切换方向后的仓库候选和旧请求重试。真实服务与人工截图验收另行记录。
@@ -64,8 +64,6 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService'
&& ['com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort', 'com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels'].includes(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
@@ -84,7 +82,6 @@ test('fleet stays a business slice and its policies have no infrastructure depen
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
'TransportTaskService->photo.FleetResourcePhotoModels',
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
@@ -19,11 +19,6 @@ public interface DomesticWarehouseDirectoryClient {
ApiResponse<ReceivingWarehouse> receivingWarehouse(@PathVariable("warehouseId") long warehouseId,
@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) { }
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
@@ -1,27 +0,0 @@
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,8 +91,6 @@ import java.util.stream.Collectors;
@Service
public class DeliveryTransferService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@Resource private DispatchAggregateRepository dispatches;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@@ -528,7 +526,6 @@ public class DeliveryTransferService {
return pickResolved(warehouseId, operatorId, taskNo, request, false);
}
/** PDA 真实出库在扣库存前校验同一扫码的仓租冻结,原幂等结果优先恢复。 */
private DeliveryResponses.PdaTask pickResolved(String warehouseId, String operatorId, String taskNo,
DeliveryRequests.PdaPick request, boolean palletScan) {
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
@@ -563,9 +560,6 @@ public class DeliveryTransferService {
if (item == null || !customerId.equals(item.getCustomerId())
|| !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");
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())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) {
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) && StringUtils.hasText(item.getPalletNo()) && !palletScan)
@@ -735,22 +729,9 @@ public class DeliveryTransferService {
return taskResult(outbound, task);
}
/** 整托扫描使用与资金预校验完全一致的实际子来源,任一失败则整托回滚。 */
/** 扫描整托前核平全部指定来源,任一不足时不执行部分拣货。 */
@Transactional
public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo,
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 code = required(taskNo, "PDA task number is required");
@@ -771,7 +752,7 @@ public class DeliveryTransferService {
.stream().filter(item -> palletCode.equals(item.getPalletNo())).toList();
if (palletItems.isEmpty()) throw notFound("当前出库任务没有该托盘");
if (palletItems.stream().allMatch(item -> value(item.getPickedPackageCount()) >= value(item.getPackageCount()))) {
return java.util.List.of();
return taskResult(outbound, task);
}
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
@@ -782,9 +763,14 @@ public class DeliveryTransferService {
!task.getCustomerId().equals(carton.getCustomerId()) || !expectedMarks.contains(carton.getCartonMark())
|| !"OUTBOUND_PENDING".equals(carton.getStatus())))
throw conflict("托盘内容或出库预留已变化,请刷新核对");
return palletItems.stream().filter(item -> value(item.getPickedPackageCount()) < value(item.getPackageCount()))
.sorted(Comparator.comparing(OutboundPalletItem::getId)).map(item -> new DeliveryRequests.PdaPick(item.getId(), null, 1L,
childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks()))) .toList();
DeliveryResponses.PdaTask result = taskResult(outbound, task);
for (OutboundPalletItem item : palletItems.stream().sorted(Comparator.comparing(OutboundPalletItem::getId)).toList()) {
if (value(item.getPickedPackageCount()) >= value(item.getPackageCount())) continue;
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<>();
@@ -811,8 +797,13 @@ public class DeliveryTransferService {
if (remaining > 0) throw conflict("托盘可用库存不足,整托拣货未执行:" + palletCode);
}
return plans.stream().map(plan -> new DeliveryRequests.PdaPick(plan.itemId(), plan.positionId(), plan.packages(),
palletPickOperationId(request.idempotencyKey(), code, palletCode, plan), plan.cartonMarks())).toList();
DeliveryResponses.PdaTask result = taskResult(outbound, task);
for (PalletPickPlan plan : plans) {
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
@@ -1020,7 +1011,7 @@ public class DeliveryTransferService {
delivery.setUpdatedAt(now); cancellations.save(delivery);
}
OutboundPdaTask task = cancellations.task(outbound);
if (task != null) { storageRent.cancelExecution("OUTBOUND",task.getTaskNo()); task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); }
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); }
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); cancellations.save(outbound);
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); cancellations.save(item); }
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
@@ -116,13 +116,17 @@ public class DispatchDeliveryFees {
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)
throw conflict("派送费用类型、方向、币种或金额不合法");
if ("RECEIVABLE".equals(fee.direction())) 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)))
throw conflict("本组该服务已全部覆盖,不得重复登记应收");
}
if ("BUY_APPOINTMENT".equals(input.appointmentAccount()) && blank(input.sellerName()))
throw conflict("买约须填写卖家名称");
var required=new HashSet<String>(); required.add("DELIVERY_FEE");
if (Set.of("FLOOR","MIXED").contains(input.loadingType())) required.add("LOADING_FEE");
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("本组尚有未覆盖的服务费用,请核对" );
}
/** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */
@@ -130,7 +134,7 @@ public class DispatchDeliveryFees {
fees.requireNoActiveDeliveryReceivables(customerId,freight.priorInboundCodes());
}
/** 外接报价保留记录;只发布供应商应付,客户应收走人工审核。 */
/** 费用与子单同事务保存,只向真实客户目的地发布一次正数来源。 */
public void publish(Context context,Group group,ArrangeGroup input,DeliveryOrder delivery,
DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight,String operatorId,String arrangementKey) {
var now=LocalDateTime.now();
@@ -140,10 +144,12 @@ public class DispatchDeliveryFees {
operation.setOperatorId(operatorId);operation.setOperationNode("CONFIRM_EXTERNAL_FREIGHT");
operation.setResult("外接预订 "+quote.bookingNo()+";实收确认版本 "+quote.recordVersion()+";约定运费 "+quote.currency()+" "+quote.amount().toPlainString());
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()) {
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 orders=charged.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList();
var inbound=charged.stream().map(CargoLine::inboundCode).filter(Objects::nonNull).distinct().toList();
@@ -158,7 +164,7 @@ public class DispatchDeliveryFees {
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",
delivery.getId(),delivery.getDeliveryNo(),orderId,null,inboundCode,null,null,null,Long.valueOf(delivery.getOverseasWarehouseId()),
code,name, null,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(),
code,name,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.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());
@@ -16,12 +16,8 @@ public final class PdaCargoDiscardDtos {
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
@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); }
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
}
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
@@ -23,7 +23,6 @@ public class PdaCargoDiscardService {
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private CargoDiscardService discardService;
@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) {
@@ -96,9 +95,6 @@ public class PdaCargoDiscardService {
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
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();
for (CargoDiscardItem item : palletItems) {
if ("COMPLETED".equals(item.getPdaStatus())) {
@@ -50,7 +50,6 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@Resource private CargoDiscardStatisticsService statisticsService;
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
@@ -390,7 +389,6 @@ public class CargoDiscardService {
release(customerId, application, links, items.listApplication(customerId, application.getId()));
LocalDateTime at = now();
String from = application.getStatus();
storageRent.cancelExecution("DISCARD",discardNo);
application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy());
application.setReviewRemark(platform ? "平台取消" : application.getReviewRemark());
application.setUpdatedAt(at); applications.updateById(application);
@@ -122,7 +122,7 @@ public interface DispatchAggregateMapper {
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
WHERE id=#{r.id} AND record_version=#{version}
""")
@@ -11,8 +11,6 @@ import java.util.List;
public interface DispatchCandidateMapper {
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
/** 完成入库或已确认短少后,不再把未实收的预留托盘当作在途货;真实库存仍可排车。 */
String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')";
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
String DEPARTED_PLAN =
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
@@ -180,7 +178,7 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
@@ -285,7 +283,7 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
@@ -33,7 +33,7 @@ public interface DispatchSourceMapper {
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
@@ -41,23 +41,6 @@ public interface DispatchSourceMapper {
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
/** 只有原客户、原计划、原仓已结束且逐项明确为零的来源可移除;缺记录不代表短少。 */
@Select("""
SELECT COUNT(*) FROM ll_inbound_plan_pallet p
JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan}
AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse}
AND ip.status IN ('COMPLETED','PARTIAL_INBOUND')
AND COALESCE(p.actual_package_count,0)=0
AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id
AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0))
AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id
WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
""")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse);
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
@@ -132,7 +115,7 @@ public interface DispatchSourceMapper {
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')")
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
@@ -68,8 +68,6 @@ public class DispatchSourceRepository {
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
/** 空预排来源必须具备已完成的零实收事实,不能把来源缺失当作零。 */
public boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; }
/** 取得已有真实退回候选。 */
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
/** 同托真实来源订单组成。 */
@@ -32,9 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service
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);
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@@ -312,7 +310,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
Resources resources=masterPhotos.populate(id,actor.operatorId(),resources(shell,photos,null)); row.setResourcesJson(write(resources));
Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources));
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
@@ -378,7 +376,6 @@ 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);
releaseSources(row,actor,items,now());
storageRent.cancelExecution("DISPATCH",number);
fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消");
return saveCommand(row,actor,identity,request.reason());
}
@@ -446,7 +443,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class)));
DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row));
var savedResources=masterPhotos.populate(row.getId(),actor.operatorId(),resources(actual,photos,request.maintenance()));
var savedResources=resources(actual,photos,request.maintenance());
// 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。
var savedDriver=savedResources.driver();
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
@@ -736,25 +733,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
long version=row.getRecordVersion();
// 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。
var all=repository.cargo(row.getId());
boolean removedItems=!Objects.equals(row.getItemCount(),all.size());
int previousPackages=row.getPackageCount();
row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum()));
row.setItemCount(all.size());
row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")));
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1));
if(removedItems || previousPackages!=row.getPackageCount()) {
var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
}
if(all.isEmpty()) {
invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核");
row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false;
}
if(preserveReview)row.setReviewBasisHash(basis(row));
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
@@ -797,22 +775,21 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
}
/** 车辆和司机须有有效图片,驾驶证接受与档案一致的PDF或图片。 */
/** 三类均须含至少一张当前有效图片;保存草稿不调用此送审/通过门禁。 */
private void verifyReviewPhotos(DispatchAggregate row,Resources resources) {
var driver=resources.driver();var proof=driver.evidence();
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片");
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片");
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片");
}
/** 文件存在、归属及有效性由原提供者复验,PDF仅可作为驾驶证。 */
/** 文件存在、归属及有效性由原文件提供者复验,PDF不能充当照片。 */
private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) {
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label);
boolean image=false;
for(var id:ids) {
var master=masterEvidenceDriverId(row,kind,id);
var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id);
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())
|| kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER && "application/pdf".equals(file.contentType())))image=true;
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())))image=true;
}
if(!image)throw bad("请上传至少一张有效"+label);
}
@@ -67,13 +67,7 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
boolean preserveReview=workflow.currentReceiptBasis(row);
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
var empty=new ArrayList<DispatchItem>();
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) {
empty.add(item);
continue;
}
for(var item:pending) {
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
received.put(item,actual);
@@ -85,25 +79,14 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg());
entry.getKey().setVolumeCbm(entry.getValue().volumeCbm());
}
for(var item:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
}
// 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。
if(!empty.isEmpty()) {
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null);
var original=physicalClaims.lockOwner(owner);
for(var item:empty) {
if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId())))
throw conflict("空托盘已有真实拣货,不能按短少移除");
if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化");
}
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
}
var pallets=received.keySet().stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
for(var entry:received.entrySet()) {
var item=entry.getKey();var actual=entry.getValue();
@@ -112,8 +95,16 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
}
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList());
for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化");
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList());
int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum());
if(total!=row.getPackageCount()) {
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(number,total,stops);
}
row.setPackageCount(total);
row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
workflow.saveReceivedCargo(row,operator,at,preserveReview);
}
}
@@ -1,66 +0,0 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind;
import com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER;
/** 档案照片只在缺少本车照片时带入,复制为本车证据以冻结历史与文件权限。 */
@Service
public class DispatchMasterPhotoService {
private final FleetResourcePhotoPort photos;
private final DispatchEvidenceGateway evidence;
/** 只读主档并通过本车文件网关创建证据。 */
public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) {
this.photos=photos; this.evidence=evidence;
}
/** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */
public Resources populate(long dispatchId,String operator,Resources value) {
var v=value.vehicle(); var d=value.driver();
if(v!=null && v.mode()==MASTER && v.vehicleId()!=null) {
var ids=populate(dispatchId,operator,Kind.VEHICLE,v.vehicleId(),v.evidenceFileIds());
v=new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),ids);
}
if(d!=null && d.mode()==MASTER && d.driverId()!=null && d.evidence()!=null) {
var e=d.evidence();
var ids=populate(dispatchId,operator,Kind.DRIVER,d.driverId(),e.photoFileIds());
d=new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),new DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt(),ids,e.masterLicenseFileId()));
}
return new Resources(v,d,value.maintenance());
}
/** 只复制已经由档案服务确认的图片,上传失败使业务事务整体失败。 */
public List<Long> populate(long dispatchId,String operator,Kind kind,long resourceId,List<Long> existing) {
if(existing!=null && !existing.isEmpty())return List.copyOf(existing);
var current=photos.current(kind,resourceId);
if(current.photo()==null)return List.of();
var source=photos.download(kind,resourceId,Long.parseLong(current.photo().fileId()));
var file=new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content());
var copied=evidence.upload(dispatchId,kind==Kind.VEHICLE?DispatchEvidenceGateway.Kind.VEHICLE:DispatchEvidenceGateway.Kind.DRIVER_PHOTO,operator,file);
return List.of(copied.fileId());
}
/** 只供服务间复制已核验图片,不接受任意磁盘路径。 */
private record SnapshotFile(String filename,String contentType,byte[] bytes) implements MultipartFile {
/** 上传部件固定使用文件字段。 */
@Override public String getName() { return "file"; }
/** 保留已核验主档文件名。 */
@Override public String getOriginalFilename() { return filename; }
/** 沿用已核验图片类型。 */
@Override public String getContentType() { return contentType; }
/** 拒绝空图片。 */
@Override public boolean isEmpty() { return bytes.length==0; }
/** 使用实际读取的字节长度。 */
@Override public long getSize() { return bytes.length; }
/** 返回本次复制的图片内容。 */
@Override public byte[] getBytes() { return bytes; }
/** 提供上传流,不读取磁盘文件。 */
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
/** 实现文件上传协议要求的输出方法。 */
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(),bytes); }
}
}
@@ -23,7 +23,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
@Service
public class DispatchPickingService {
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@jakarta.annotation.Resource private DispatchService sourceRules;
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
@@ -67,10 +66,6 @@ public class DispatchPickingService {
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());
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) {
attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId());
returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId();
@@ -36,7 +36,6 @@ public class TransportTaskService {
private final TripOperationsEvidenceGateway files;
private final ObjectMapper json;
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService masterPhotos;
/** 复用已有资格、占用、原单及凭证能力。 */
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
@@ -120,20 +119,6 @@ public class TransportTaskService {
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
// 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。
var supplied=request.reviewPhotos();
var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE,
request.vehicleId(),supplied==null?List.of():supplied.vehicleFileIds());
var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,
request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds());
var license=request.masterLicenseFileId();
if(license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) {
var driver=drivers.detail(request.driverId(),null);
if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId());
}
request=new TransportTaskModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason(),
request.dispatchRecordVersion(),new com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments(
vehiclePhotos,driverPhotos,supplied==null?List.of():supplied.licenseFileIds()),license);
photos=reviewPhotos.validate(row,trip,request);
}
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
@@ -46,7 +46,6 @@ public final class LastlegOrderRequests {
if (page == null || page == 0) page = 1L;
if (size == null || size == 0) size = 20L;
}
}
public record ContainerProgressQuery(
@@ -101,7 +100,6 @@ public final class LastlegOrderRequests {
public record CreateFee(
@NotBlank @Size(max = 64) String feeItemCode,
@NotBlank @Size(max = 200) String feeItemName,
@Size(max = 500) String feeRemark,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotBlank @Pattern(regexp = "CNY|USD") String currency,
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
@@ -1,20 +0,0 @@
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) { }
}
@@ -1,44 +0,0 @@
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)));
}
}
}
@@ -1,35 +0,0 @@
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,7 +1,6 @@
package com.baiyue.lastleg.financefee.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest;
@@ -11,10 +10,6 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
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 lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@@ -37,26 +32,20 @@ import java.util.List;
@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources")
public class PlatformOverseasFeeSourceController {
private final OverseasFeeSourceService service;
private final OverseasManualFeeService manual;
private final OverseasFeeDeliveryService delivery;
/** 查询海外费用当前版本。 */
@GetMapping
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<List<OverseasFeeSource>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String businessType,
@RequestParam(required = false) String businessNo,
@RequestParam(required = false) Long containerBookingId) {
requirePlatform(user);
var result = manual.describe(service.list(customerId, businessType, businessNo, containerBookingId));
delivery.describe(result);
return ApiResponse.success(result);
return ApiResponse.success(service.list(customerId, businessType, businessNo, containerBookingId));
}
/** 查询真实柜下可计费客户。 */
@GetMapping("/container-customers")
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<List<ContainerCustomerOption>> listContainerCustomers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long containerBookingId) {
@@ -64,139 +53,47 @@ public class PlatformOverseasFeeSourceController {
return ApiResponse.success(service.listContainerCustomers(containerBookingId));
}
/** 创建人工费用并开始锁定余额及授信。 */
@PostMapping
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody SaveRequest request) {
requirePlatform(user); return ApiResponse.success(manual.create(operator(user), key, request));
requirePlatform(user); return ApiResponse.success(service.create(operator(user), key, request));
}
/** 重新提交费用审核。 */
@PostMapping("/{sourceFeeId}/submit")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
requirePlatform(user); return ApiResponse.success(manual.submit(sourceFeeId, operator(user), request.idempotencyKey()));
requirePlatform(user); return ApiResponse.success(service.submit(sourceFeeId, operator(user), request.idempotencyKey()));
}
/** 审核费用并推进对应结算。 */
@PostMapping("/{sourceFeeId}/review")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<OverseasFeeSource> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ReviewRequest request) {
requirePlatform(user); return ApiResponse.success(manual.review(sourceFeeId, operator(user), operatorName(user), request));
requirePlatform(user); return ApiResponse.success(service.review(sourceFeeId, operator(user), request));
}
/** 提交账单费用的调整版本。 */
@PostMapping("/{sourceFeeId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody AdjustRequest request) {
requirePlatform(user); return ApiResponse.success(manual.adjust(sourceFeeId, operator(user), request));
requirePlatform(user); return ApiResponse.success(service.adjust(sourceFeeId, operator(user), request));
}
/** 申请作废已推送账单费用。 */
@PostMapping("/{sourceFeeId}/void")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody VoidRequest request) {
requirePlatform(user); return ApiResponse.success(manual.voidSource(sourceFeeId, operator(user), request));
requirePlatform(user); return ApiResponse.success(service.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) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L)
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可操作海外费用");
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
}
/** 记录当前审核或创建操作人。 */
private static String operator(AuthenticatedUser user) {
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,14 +17,11 @@ public final class OverseasFeeSourceDtos {
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(
@NotBlank @Size(max = 128) String customerId,
@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|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,
@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,
@Min(1) Long businessId,
@NotBlank @Size(max = 128) String businessNo,
@Min(1) Long orderId,
@@ -36,14 +33,11 @@ public final class OverseasFeeSourceDtos {
@Min(1) Long warehouseId,
@NotBlank @Size(max = 64) String feeItemCode,
@NotBlank @Size(max = 200) String feeItemName,
/** 业务人员补收或重复作业时填写的简短说明,和审核驳回原因分开保存。 */
@Size(max = 500) String feeRemark,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@Min(1) Long supplierId,
@Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory,
/** 应收可选 BILL(推送结算)或 WALLET(余额扣费);应付仍使用账期/已付。 */
@Pattern(regexp = "BILL|WALLET|ON_ACCOUNT|DIRECT_PAID") String settlementMode,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@@ -59,16 +53,9 @@ public final class OverseasFeeSourceDtos {
) { }
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,
@Size(max = 500) String reason,
@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);}
}
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record AdjustRequest(@NotNull @Min(1) Integer expectedVersion,
@NotNull SaveRequest fee,
@NotBlank @Size(max = 128) String idempotencyKey) { }
@@ -1,29 +0,0 @@
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;
}
@@ -1,25 +0,0 @@
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,12 +15,6 @@ import java.util.List;
@Data
@TableName(value = "ll_finance_fee_source", autoResultMap = true)
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;
private Long sourceFeeId;
private Integer sourceVersion;
@@ -29,7 +23,6 @@ public class OverseasFeeSource {
private String sourceType;
private String sourceNo;
private String businessType;
@TableField(exist = false) private String displayBusinessType;
private Long businessId;
private String businessNo;
private Long orderId;
@@ -41,8 +34,6 @@ public class OverseasFeeSource {
private Long warehouseId;
private String feeItemCode;
private String feeItemName;
/** 费用业务说明,不用于记录审核结论。 */
private String feeRemark;
private String direction;
private Long supplierId;
private String supplierName;
@@ -59,8 +50,6 @@ public class OverseasFeeSource {
private BigDecimal amount;
private String originalCurrency;
private BigDecimal originalAmount;
/** 免费仓租显式写入空汇率,避免存量库默认1被误当美元调价汇率。 */
@TableField(insertStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private BigDecimal exchangeRate;
private String exchangeRateMonth;
private BigDecimal chargeableQuantity;
@@ -72,21 +61,8 @@ public class OverseasFeeSource {
private String publicationStatus;
private String sourceStatus;
@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;
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
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 String createdBy;
private String updatedBy;
@@ -1,104 +0,0 @@
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>";
}
}
}
@@ -1,15 +0,0 @@
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);
}
@@ -1,36 +0,0 @@
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);
}
@@ -1,97 +0,0 @@
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>";
}
}
}
@@ -1,38 +0,0 @@
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); }
}
@@ -1,27 +0,0 @@
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,13 +19,9 @@ public class OverseasFeeOutboxRepository {
public boolean prerequisitePublished(LastlegEventOutbox row) { return cancellation.prerequisitePublished(row); }
/** 仅投递需结算的费用,已确认余额扣款的应收不再发送,避免无效重试占满队列。 */
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime stale) {
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
.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(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
@@ -1,72 +0,0 @@
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); }
}
@@ -1,67 +0,0 @@
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 org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@@ -17,24 +17,22 @@ import java.util.List;
@Component
@RequiredArgsConstructor
public class OverseasFeeBusinessGuard {
private final OverseasFeeBusinessRepository repository;
private final JdbcTemplate jdbc;
@Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
/** 按真实来源柜一次读取客户候选。 */
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
return repository.containerCustomers(String.valueOf(containerBookingId));
List<ContainerCustomerOption> customers = jdbc.query(
"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) {
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())) {
requireOwnedContainer(fee);
return;
@@ -47,21 +45,28 @@ public class OverseasFeeBusinessGuard {
requireActiveOverseasWarehouse(fee.warehouseId());
return;
}
int count;
try {
count = repository.owned(fee.businessType(), fee.businessId(), fee.customerId().trim(), fee.businessNo().trim());
} catch (IllegalArgumentException exception) {
throw bad("业务类型与业务记录ID不匹配");
}
if (count != 1) throw bad("费用关联业务不存在或不属于该客户");
if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())
&& repository.inbound(fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim()) < 1) {
throw bad("入仓码不属于该客户和海外入仓计划");
Table table = switch (fee.businessType()) {
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no");
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no");
case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no");
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no");
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no");
case "LASTLEG_ORDER" -> new Table("ll_order", "order_no");
case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no");
case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no");
default -> throw bad("业务类型与业务记录ID不匹配");
};
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) {
if (fee.businessId() == null || fee.containerBookingId() == null
|| !fee.businessId().equals(fee.containerBookingId()))
@@ -70,14 +75,23 @@ public class OverseasFeeBusinessGuard {
throw bad("海外货柜费用的业务编号必须与真实集装箱号一致");
String bookingId = String.valueOf(fee.containerBookingId());
if (repository.container(bookingId) < 1) throw bad("费用关联的真实货柜不存在");
if (repository.inconsistentContainer(bookingId, fee.containerNo().trim()) > 0)
throw bad("真实货柜的集装箱号缺失或不一致");
if (repository.containerCustomer(bookingId, fee.customerId().trim()) < 1)
throw bad("该客户不属于费用关联的真实货柜");
}
Integer total = jdbc.queryForObject(
"SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=?",
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("真实货柜的集装箱号缺失或不一致");
Integer owned = jdbc.queryForObject(
"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) {
try {
@@ -94,10 +108,9 @@ public class OverseasFeeBusinessGuard {
}
}
/** 判断业务可选字段是否为空。 */
private static boolean blank(String value) { return value == null || value.isBlank(); }
/** 返回业务参数错误。 */
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private record Table(String name, String number) { }
}
@@ -1,64 +0,0 @@
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;
}
}
@@ -1,217 +0,0 @@
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,9 +26,7 @@ public class OverseasFeeOutboxService {
@Resource private LastlegEventOutboxMapper mapper;
@Resource private ObjectMapper objectMapper;
/** 仅推送账单费用;已实扣的海外应收保留本地明细,不再重复入财务结算池。 */
public void publish(OverseasFeeSource source, String action) {
if ("RECEIVABLE".equals(source.getDirection()) && source.getWalletSettlement() != null) return;
BusinessFeeSourceChangedV1 unsigned = payload(source, null, null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
@@ -98,11 +98,10 @@ public class OverseasFeeSourceService {
return value;
}
/** 自动确认仅推进新草稿,已驳回的原费用不得重新审核发布。 */
@Transactional
public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) {
OverseasFeeSource value = create(operatorId, key, request);
if ("DRAFT".equals(value.getReviewStatus())) {
if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) {
value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit"));
}
if ("PENDING".equals(value.getReviewStatus())) {
@@ -115,48 +114,10 @@ public class OverseasFeeSourceService {
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,
com.baiyue.common.event.WalletSettlementReference settlement) { }
/** 实际出库先登记待审核仓租,沿用冻结汇率,不发账单或扣款。 */
@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;
}
/** 实扣成功后登记海外费用,沿用已确认汇率和资金流水,不再推送账单结算。 */
/** Internal business entry: money has already succeeded and its confirmed FX must not be recalculated. */
@Transactional
public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion conversion,
@@ -178,24 +139,23 @@ public class OverseasFeeSourceService {
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion);
value.setWalletSettlement(settlement);
value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED");
value.setReviewerId(operatorId); value.setReviewerName(operatorId); value.setReviewedAt(now());
value.setReviewReason("业务费用已扣款");
value.setReviewerId(operatorId); value.setReviewedAt(now());
value.setReviewReason("已核定业务费用扣款成功后发布");
mapper.insert(value);
outbox.publish(value, "PUBLISHED");
command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1);
return value;
}
/** 旧提交入口同样以驳回为终态,保留原审核记录。 */
@Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType());
// 驳回代表本条费用终结,录错后必须新建费用,不能覆盖原审核记录。
if (!"DRAFT".equals(value.getReviewStatus()))
throw conflict("已驳回费用不能重新提交,请重新新增费用");
value.setReviewStatus("PENDING"); value.setReviewReason(null); value.setReviewerId(null); value.setReviewerName(null); value.setReviewedAt(null);
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus()))
throw conflict("只有草稿或已驳回费用可以提交");
value.setReviewStatus("PENDING"); value.setReviewReason(null);
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
command(operatorId, "SUBMIT", key, sourceFeeId, sourceFeeId, value.getSourceVersion());
return value;
@@ -203,26 +163,16 @@ public class OverseasFeeSourceService {
@Transactional
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);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
throw conflict("只有待审核的有效费用可以审核");
if (request.expectedVersion() != null && !Objects.equals(value.getSourceVersion(), request.expectedVersion()))
throw conflict("费用版本已变化,请刷新后重新审核");
boolean approved = "APPROVE".equals(request.decision());
if (!approved && text(request.reason()) == null) throw bad("请填写驳回原因");
value.setReviewStatus(approved ? "APPROVED" : "REJECTED");
value.setPublicationStatus(approved ? "PUBLISHED" : "UNPUBLISHED");
value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId);
value.setReviewerName(text(reviewerName) == null ? operatorId : text(reviewerName)); value.setReviewedAt(now());
value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId); value.setReviewedAt(now());
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
if (approved) outbox.publish(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED");
command(operatorId, "REVIEW", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion());
@@ -278,7 +228,6 @@ public class OverseasFeeSourceService {
return value;
}
/** 保存费用及业务说明快照,和审核原因分别记录。 */
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion confirmedConversion) {
OverseasFeeSource value = new OverseasFeeSource();
@@ -289,7 +238,6 @@ public class OverseasFeeSourceService {
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.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.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
@@ -307,7 +255,6 @@ public class OverseasFeeSourceService {
return value;
}
/** 校验业务来源、费用项目与应收应付信息,不允许不同业务类型混挂。 */
private void validate(SaveRequest value) {
String expected = switch (value.businessType()) {
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
@@ -315,7 +262,7 @@ public class OverseasFeeSourceService {
case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE";
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
case "WAREHOUSE_SERVICE", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "DISCARD_ORDER" -> "OVERSEAS_WAREHOUSE_FEE";
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE";
case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE";
case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE";
case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE";
@@ -323,11 +270,6 @@ public class OverseasFeeSourceService {
default -> "";
};
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())
&& !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim()))
throw bad("海外货柜费用项目不受支持");
@@ -340,11 +282,9 @@ public class OverseasFeeSourceService {
boolean payable = "PAYABLE".equals(value.direction());
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if (!payable) {
// 应收结算选择不代表供应商已付事实,两类信息仍严格隔离。
if (value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode())
|| value.supplierId() != null || text(value.supplierName()) != null || text(value.supplierCategory()) != null
if (value.supplierId() != null || text(value.supplierName()) != null || text(value.settlementMode()) != null
|| value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files)
throw bad("应收费用只能选择推送结算或余额扣费,不得携带付款信息");
throw bad("应收费用不得携带供应商或付款信息");
return;
}
if (value.supplierId() == null || text(value.supplierName()) == null || !Set.of("ON_ACCOUNT", "DIRECT_PAID").contains(value.settlementMode()))
@@ -1,528 +0,0 @@
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); }
}
@@ -1,13 +0,0 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
import java.util.List;
/** 费用域通过端口委托仓租审核,避免依赖仓租实现。 */
public interface StorageRentFeeWorkflow {
/** 仓租来源标记,写操作仍核对持久化归属。 */
static boolean isRent(OverseasFeeSource fee) { return "STORAGE_RENT".equals(fee.getCreatedBy()) && "FEE_TYPE_077".equals(fee.getFeeItemCode()); }
/** 附加原计租批次和实际出库明细。 */
void describe(List<OverseasFeeSource> rows);
/** 按原冻结审核仓租,批准后申请实际扣款。 */
OverseasFeeSource review(long id,String operator,String name,ReviewRequest request);
}
@@ -27,10 +27,7 @@ public final class OutsourcedBusinessModels {
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加装车照片。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
@@ -53,7 +53,7 @@ public class OutsourcedBusinessService {
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
if(request==null)throw bad("照片绑定请求缺失");
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId(),request.replaceFileId()),SCOPE));
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
}
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
@@ -34,11 +34,7 @@ public final class OwnedBusinessModels {
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
@Size(max=1000) String reason) {}
public record BindPhoto(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加装车照片。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
}
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
public record Gate(boolean allowed,String blockReason) {}
public record Actions(Gate edit,Gate upload) {}
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
@@ -8,11 +8,7 @@ import org.springframework.web.multipart.MultipartFile;
public interface DispatchLoadingPhotoPort {
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加照片,每类最多五张。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
}
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
record State(String recordVersion,List<Photo> photos) {}
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
record Download(Photo photo,byte[] content) {}
@@ -77,9 +77,9 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed);
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
row.setPhotosJson(codec.write(photos));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
var photos=new EnumMap<PhotoSlot,OwnedBusinessModels.Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
@@ -104,7 +104,6 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
private void validate(Slot slot,BindPhoto request) {
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效");
}
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
@@ -1,33 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Photo;
import java.util.ArrayList;
import java.util.List;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 照片按文件追加或精确替换,禁止按类别压缩而丢失其他已保存图片。 */
final class OwnedBusinessPhotoBindings {
/** 工具类仅提供照片引用合并。 */
private OwnedBusinessPhotoBindings() {}
/** 装车每类最多五张;油耗仍为单张,替换目标必须在当前同类引用中。 */
static List<Photo> bind(List<Photo> existing,Photo photo,String replaceFileId) {
var result=new ArrayList<>(existing);
// 油耗沿原单张替换协议,不能因装车改多图而改变其他入口的保存行为。
if(replaceFileId==null && "FUEL".equals(photo.category()))
replaceFileId=existing.stream().filter(p->p.slot()==photo.slot()).map(Photo::fileId).findFirst().orElse(null);
if(replaceFileId!=null) {
int index=-1;
for(int i=0;i<result.size();i++)if(result.get(i).slot()==photo.slot() && result.get(i).fileId().equals(replaceFileId))index=i;
if(index<0)throw conflict("待替换照片已变化,请刷新后核对");
result.remove(index);
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
result.add(index,photo);
} else {
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
int limit="LOADING".equals(photo.category())?5:1;
if(result.stream().filter(p->p.slot()==photo.slot()).count()>=limit)throw conflict("该类照片最多"+limit+"张,请替换已有照片");
result.add(photo);
}
return List.copyOf(result);
}
}
@@ -69,20 +69,20 @@ public class OwnedBusinessRecordService {
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
requireVersion(row,version);
var saved=new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
row.setPhotosJson(codec.write(photos));
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
}
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
private String photoReason(PhotoSlot slot) {
return switch(slot) {
case LOADING_SCENE -> "保存司机驾驶证照片";
case LOADING_LIST -> "保存车牌照片";
case PRE_DEPARTURE_CHECK -> "保存装车完成照片";
case DRIVER_LICENSE -> "保存封车照片";
case RECEIPT -> "保存BOL司机签字照片";
case LOADING_SCENE -> "保存装车现场照片";
case LOADING_LIST -> "保存装车清单照片";
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
case DRIVER_LICENSE -> "保存驾驶证附件照片";
case RECEIPT -> "保存签收凭证照片";
case FUEL_START -> "保存起始油耗表盘照片";
case FUEL_END -> "保存结束油耗表盘照片";
};
@@ -1,14 +0,0 @@
package com.baiyue.lastleg.fleet.photo;
import org.springframework.web.multipart.MultipartFile;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** File按平台、档案类型、资源身份及文件身份隔离照片。 */
public interface FleetResourcePhotoGateway {
/** 上传平台所属的档案照片。 */
Photo upload(Kind kind, long id, String operator, MultipartFile file);
/** 核验档案类型、资源身份和文件身份。 */
Photo metadata(Kind kind, long id, long file);
/** 读取已核验归属的图片内容。 */
byte[] download(Kind kind, long id, long file);
}
@@ -1,17 +0,0 @@
package com.baiyue.lastleg.fleet.photo;
import org.apache.ibatis.annotations.*;
/** 仅操作档案照片版本表;资源有效性通过所属领域服务核验。 */
@Mapper
public interface FleetResourcePhotoMapper {
/** 读取同一档案的最大照片版本。 */
@Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} ORDER BY record_version DESC LIMIT 1")
FleetResourcePhotoModels.Stored current(@Param("kind") String kind, @Param("id") long id);
/** 按档案与文件复合身份查询历史引用。 */
@Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} AND file_id=#{file} LIMIT 1")
FleetResourcePhotoModels.Stored file(@Param("kind") String kind, @Param("id") long id, @Param("file") long file);
/** 追加不可变照片版本,数据库拒绝重复资源版本。 */
@Insert("INSERT INTO ll_fleet_resource_photo(resource_kind,resource_id,record_version,file_id,file_name,content_type,size_bytes,created_by,created_at) VALUES(#{resourceKind},#{resourceId},#{recordVersion},#{fileId},#{fileName},#{contentType},#{sizeBytes},#{createdBy},#{createdAt})")
int insert(FleetResourcePhotoModels.Stored row);
}
@@ -1,15 +0,0 @@
package com.baiyue.lastleg.fleet.photo;
import java.time.LocalDateTime;
/** 档案照片独立版本化,历史图片不覆盖且不改变驾驶证审核结论。 */
public final class FleetResourcePhotoModels {
/** 仅承载照片接口与持久化数据。 */
private FleetResourcePhotoModels() { }
public enum Kind { VEHICLE, DRIVER }
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
public record Detail(long recordVersion, Photo photo) { }
public record Stored(String resourceKind, long resourceId, long recordVersion, long fileId,
String fileName, String contentType, long sizeBytes, String createdBy, LocalDateTime createdAt) { }
public record Download(Photo metadata, byte[] content) { }
}
@@ -1,18 +0,0 @@
package com.baiyue.lastleg.fleet.photo;
import org.springframework.stereotype.Repository;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 同一资源版本唯一,阻止并发替换静默覆盖。 */
@Repository
public class FleetResourcePhotoRepository {
private final FleetResourcePhotoMapper mapper;
/** 注入唯一照片Mapper。 */
public FleetResourcePhotoRepository(FleetResourcePhotoMapper mapper) { this.mapper = mapper; }
/** 读取指定档案的最新照片版本。 */
public Stored current(Kind kind, long id) { return mapper.current(kind.name(), id); }
/** 按档案及文件身份读取不可变历史引用。 */
public Stored file(Kind kind, long id, long file) { return mapper.file(kind.name(), id, file); }
/** 追加新版本,唯一键负责并发冲突保护。 */
public void append(Stored row) { if (mapper.insert(row) != 1) throw new IllegalStateException("档案照片未保存"); }
}
@@ -1,63 +0,0 @@
package com.baiyue.lastleg.fleet.photo;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 管理可重复用于排车的车辆及司机照片,排车另存当趟不可变副本。 */
@Service
public class FleetResourcePhotoService implements com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort {
private final FleetResourcePhotoRepository repository;
private final FleetResourcePhotoGateway files;
private final VehicleRepository vehicles;
private final DriverService drivers;
/** 通过资源领域入口确认身份,所有照片版本经Repository保存。 */
public FleetResourcePhotoService(FleetResourcePhotoRepository repository, FleetResourcePhotoGateway files,
VehicleRepository vehicles, DriverService drivers) {
this.repository=repository; this.files=files; this.vehicles=vehicles; this.drivers=drivers;
}
/** 先确认资源存在,空照片是明确的可补齐状态。 */
public Detail current(Kind kind, long id) {
requireResource(kind,id);
var row=repository.current(kind,id);
return row==null ? new Detail(0,null) : new Detail(row.recordVersion(),photo(row));
}
/** 版本随照片独立递增;失败保留旧照片及历史。 */
@Transactional
public Detail upload(Kind kind,long id,long version,String operator,MultipartFile file) {
var current=current(kind,id);
if(version!=current.recordVersion())throw conflict();
var saved=files.upload(kind,id,operator,file);
try {
repository.append(new Stored(kind.name(),id,version+1,Long.parseLong(saved.fileId()),saved.fileName(),
saved.contentType(),saved.sizeBytes(),operator,LocalDateTime.now(ZoneOffset.UTC)));
} catch(DuplicateKeyException error) { throw conflict(); }
return new Detail(version+1,saved);
}
/** 只允许读取本档案已关联的当前或历史图片。 */
public Download download(Kind kind,long id,long file) {
requireResource(kind,id);
if(repository.file(kind,id,file)==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"档案照片不存在");
var metadata=files.metadata(kind,id,file);
var bytes=files.download(kind,id,file);
if(bytes.length!=metadata.sizeBytes())throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"档案照片内容不完整");
return new Download(metadata,bytes);
}
/** 车辆及司机身份必须真实存在,不能按自由文本上传。 */
private void requireResource(Kind kind,long id) {
if(id<=0)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"档案身份无效");
if(kind==Kind.VEHICLE)vehicles.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,"车辆档案不存在")); else drivers.detail(id,null);
}
/** 转换为不暴露持久化信息的文件元数据。 */
private static Photo photo(Stored row) { return new Photo(Long.toString(row.fileId()),row.fileName(),row.contentType(),row.sizeBytes()); }
/** 冲突要求重读当前照片,不自动覆盖他人的修改。 */
private static ResponseStatusException conflict() { return new ResponseStatusException(HttpStatus.CONFLICT,"档案照片已变化,请重新读取后上传"); }
}
@@ -1,83 +0,0 @@
package com.baiyue.lastleg.fleet.photo;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpFleetResourcePhotoGateway implements FleetResourcePhotoGateway {
private static final String PATH = "/internal/file/v1/business-files/0/{type}/{record}";
private static final long MAX_SIZE = 10L * 1024 * 1024;
private final RestClient client;
private final String token;
/** 使用统一内部服务客户端与File通信。 */
public HttpFleetResourcePhotoGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token;
}
/** 只接受非空图片,通过内部File上传并校验归属。 */
@Override public Photo upload(Kind kind, long recordId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("档案照片须为非空 JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try { body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType())); }
catch (IOException error) { throw bad("无法读取档案照片"); }
var result = client.post().uri(PATH, type(kind), recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class);
return verified(result, kind, recordId, null);
}
/** 逐文件核验平台及当前档案复合归属。 */
@Override public Photo metadata(Kind kind, long recordId, long fileId) {
return verified(client.get().uri(PATH + "/{file}", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), kind, recordId, fileId);
}
/** 按原档案身份读取图片,不返回匿名URL。 */
@Override public byte[] download(Kind kind, long recordId, long fileId) {
var response = client.get().uri(PATH + "/{file}/content", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class);
return imageContent(response);
}
/** 拒绝错误正文、空文件和超过上限的内容。 */
static byte[] imageContent(ResponseEntity<byte[]> response) {
byte[] content = response.getBody();
if (content == null || content.length == 0 || content.length > MAX_SIZE
|| !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
/** 验证File返回的身份、类型及字节数。 */
static Photo verified(Envelope result, Kind kind, long recordId, Long fileId) {
if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
var data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !type(kind).equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前资源档案或不是有效照片");
return new Photo(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
/** 档案类型不可由调用方拼接任意文件业务类别。 */
private static String type(Kind kind) { return kind == Kind.VEHICLE ? "FLEET_VEHICLE_PHOTO" : "FLEET_DRIVER_PHOTO"; }
/** 车辆和司机本人照片仅允许JPG及PNG。 */
private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); }
/** 返回可修正的附件业务错误。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
/** 外部失败保留诊断,不伪装为成功。 */
private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "档案照片不可用或归属校验失败"); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -1,46 +0,0 @@
package com.baiyue.lastleg.fleet.photo;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.constraints.Min;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 复用车辆编辑、司机上传及各自列表权限,不扩大跨档案访问域。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}")
public class PlatformFleetResourcePhotoController {
private final FleetResourcePhotoService service;
/** 注入档案照片应用服务。 */
public PlatformFleetResourcePhotoController(FleetResourcePhotoService service) { this.service=service; }
/** 列表权限读取档案照片及版本。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')")
public ApiResponse<Detail> current(@PathVariable Kind kind,@PathVariable @Min(1) long id) {
return ApiResponse.success(service.current(kind,id));
}
/** 按资源类型校验上传权限并提交当前版本。 */
@PostMapping(consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("(#kind.name() == 'VEHICLE' and hasAuthority('platform:overseas-master-data:vehicle:edit')) or (#kind.name() == 'DRIVER' and hasAuthority('platform:overseas-master-data:driver:upload'))")
public ApiResponse<Detail> upload(@PathVariable Kind kind,@PathVariable @Min(1) long id,
@RequestParam @Min(0) long recordVersion,@RequestPart("file") MultipartFile file,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.upload(kind,id,recordVersion,user.userId().toString(),file));
}
/** 原档案读取权限包含其已关联的历史图片。 */
@GetMapping("/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')")
public ResponseEntity<byte[]> download(@PathVariable Kind kind,@PathVariable @Min(1) long id,@PathVariable @Min(1) long fileId) {
var file=service.download(kind,id,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.metadata().fileName(),StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
}
}

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