This commit is contained in:
2026-08-11 18:06:26 +08:00
parent a02dd3a422
commit 192964419f
101 changed files with 8892 additions and 1952 deletions
+4 -1
View File
@@ -10,7 +10,7 @@
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位)
@@ -23,6 +23,7 @@
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
@@ -34,6 +35,7 @@
- 一期:`auth`、`iam`、`warehouse`、`order`、`firstleg`、`file` 完成登录、客户/RBAC、公共仓、订单闭环;先读 `../docs/一期/README.md` 及闭环设计。
- 二期:`firstleg` 主导入库/测量/审核/计费,`warehouse` 管托盘库位,`file` 管测量图片,`integration-service` 管 OCR,`order` 仅提供货物内部查询;终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`,不实现分柜、装柜、PDA 和完整财务。
- 三期:已确认范围可按 `../docs/三期/三期开工门禁.md` 进入契约和模块实现。`firstleg` 负责履约编排、柜次、排柜、订舱、拖车、PDA 与 `LOADED` 门禁;`order`、`warehouse`、`file`、`iam`、`gateway` 和 `integration-service` 仅在各自契约需要时协作。未冻结的字段、权限、迁移或跨服务调用只阻断对应模块。
- 财务:作为跨阶段独立业务域,范围先读 `../docs/财务管理/README.md` 中已确认的产品结论与契约;`finance` 保存财务状态,`firstleg` 只提供不可变费用来源和柜次成本快照,`file` 保存收付款/成本凭证。不要把财务范围反向并入二期计费或三期履约。
## 契约、数据库与验证入口
@@ -47,5 +49,6 @@
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
进度、排期、负责人或验收状态问题再读取阶段进度表和验收报告;普通开发定位只使用范围、契约、模块指引和当前代码。
+11 -11
View File
@@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
description VARCHAR(255) DEFAULT NULL COMMENT '操作说明',
request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪',
trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID',
tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID',
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID',
tenant_id BIGINT DEFAULT NULL COMMENT '客户ID和数据隔离键;平台级审计可为空或为0',
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属客户ID;平台操作可为空或为0',
user_id BIGINT DEFAULT NULL COMMENT '操作用户ID',
username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照',
app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端',
@@ -38,17 +38,17 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
id BIGINT NOT NULL AUTO_INCREMENT,
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PROCESSING、PROCESSED、DEAD',
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,
last_error VARCHAR(1000) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
processed_at DATETIME(3) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '死信重试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间',
lease_owner VARCHAR(128) NULL COMMENT '当前补偿租约持有实例',
lease_until DATETIME(3) NULL COMMENT '当前补偿租约截止时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次补偿错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间',
processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_audit_dead_event (event_id),
KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
@@ -22,17 +22,17 @@ CALL add_audit_event_id();
DROP PROCEDURE IF EXISTS add_audit_event_id;
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
id BIGINT NOT NULL AUTO_INCREMENT,
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PROCESSING、PROCESSED、DEAD',
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,
last_error VARCHAR(1000) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
processed_at DATETIME(3) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '死信重试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间',
lease_owner VARCHAR(128) NULL COMMENT '当前补偿租约持有实例',
lease_until DATETIME(3) NULL COMMENT '当前补偿租约截止时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次补偿错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间',
processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_audit_dead_event (event_id),
KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
+9 -9
View File
@@ -8,17 +8,17 @@ CREATE TABLE IF NOT EXISTS sys_audit_outbox (
resource_id VARCHAR(128) NULL COMMENT '资源ID',
service_name VARCHAR(64) NULL COMMENT '来源服务',
payload_json JSON NOT NULL COMMENT '脱敏后的审计事件',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前周期发送次数',
compensation_count INT NOT NULL DEFAULT 0 COMMENT '自动补偿次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,
last_error VARCHAR(1000) NULL,
dead_at DATETIME(3) NULL,
sent_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间',
lease_owner VARCHAR(128) NULL COMMENT '当前租约持有者',
lease_until DATETIME(3) NULL COMMENT '发送租约截止时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次发送错误信息',
dead_at DATETIME(3) NULL COMMENT '进入死信时间',
sent_at DATETIME(3) NULL COMMENT '成功发送时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间',
PRIMARY KEY (event_id),
KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_audit_outbox_retention (status, sent_at)
+9 -9
View File
@@ -8,15 +8,15 @@ CREATE TABLE IF NOT EXISTS auth_account (
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密',
default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离',
default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID',
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM',
default_tenant_id BIGINT NOT NULL COMMENT '默认业务作用域ID:0为平台,正数为客户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体作用域ID:0为平台,正数为客户ID,用于平台端与客户端身份隔离',
default_membership_id BIGINT DEFAULT NULL COMMENT '默认客户成员关系ID;平台账号可为空',
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT企业客户端、PLATFORM平台后台',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -30,9 +30,9 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
user_id BIGINT NOT NULL COMMENT '认证账号ID',
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID',
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端',
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务作用域ID:0为平台,正数为客户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体作用域ID:0为平台,正数为客户ID',
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端:MERCHANT企业客户端、PLATFORM平台后台',
expires_at DATETIME(3) NOT NULL COMMENT '到期时间',
revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间',
@@ -40,6 +40,6 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
UNIQUE KEY uk_auth_refresh_hash (token_hash),
KEY idx_auth_refresh_user (user_id, revoked_at, expires_at),
KEY idx_auth_refresh_expire (expires_at, revoked_at)
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态';
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、平台或客户作用域、有效期和轮换/吊销状态';
-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识',
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识',
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
+9 -9
View File
@@ -5,7 +5,7 @@ USE baiyue_file;
CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
@@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS file_object (
CREATE TABLE IF NOT EXISTS file_download_token (
id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID',
token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要',
expires_at DATETIME(3) NOT NULL COMMENT '过期时间',
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS file_download_token (
CREATE TABLE IF NOT EXISTS file_download_log (
id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID',
user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID',
request_id VARCHAR(64) NULL COMMENT '请求ID',
@@ -62,14 +62,14 @@ CREATE TABLE IF NOT EXISTS file_download_log (
CREATE TABLE IF NOT EXISTS file_export_task (
id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键;平台任务固定为0',
app_code VARCHAR(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT',
task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号',
export_type VARCHAR(64) NOT NULL COMMENT '导出类型',
format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式',
task_name VARCHAR(128) NOT NULL COMMENT '任务名称',
parameters_json JSON NOT NULL COMMENT '筛选条件快照',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCEEDED/FAILED/EXPIRED',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待处理、RUNNING执行中、SUCCEEDED成功、FAILED失败、EXPIRED已过期',
total_rows BIGINT UNSIGNED NULL COMMENT '预计总行数',
processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已处理行数',
file_name VARCHAR(255) NULL COMMENT '下载文件名',
@@ -108,7 +108,7 @@ CREATE TABLE IF NOT EXISTS file_export_task (
CREATE TABLE IF NOT EXISTS file_export_task_guard (
guard_key VARCHAR(64) NOT NULL COMMENT '任务创建串行化锁键',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '锁记录最后更新时间',
PRIMARY KEY (guard_key)
) ENGINE=InnoDB COMMENT='导出任务创建并发保护表:低频创建入口使用单行锁保证重复提交及活动上限';
@@ -123,15 +123,15 @@ CREATE TABLE IF NOT EXISTS file_export_outbox (
tag VARCHAR(64) NOT NULL COMMENT '消息Tag',
message_key VARCHAR(64) NOT NULL COMMENT '消息Key',
payload_json JSON NOT NULL COMMENT '命令消息体',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CLAIMED/SENT/QUARANTINED',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待发送、CLAIMED已领取、SENT已发送、QUARANTINED隔离',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '累计发送尝试次数',
next_retry_at DATETIME(3) NOT NULL COMMENT '允许发送的最早时间',
lease_owner VARCHAR(128) NULL COMMENT '领取实例标识',
lease_until DATETIME(3) NULL COMMENT '领取租约截止时间',
sent_at DATETIME(3) NULL COMMENT '发送成功时间',
last_error VARCHAR(1000) NULL COMMENT '最后发送错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Outbox记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'Outbox记录更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_file_export_outbox_event (event_id),
UNIQUE KEY uk_file_export_outbox_task_version (task_id, dispatch_version),
+36
View File
@@ -0,0 +1,36 @@
# Finance Service Instructions
## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、收款及分配、成本及分摊、账单修订、幂等记录和财务审计状态。
- 产品范围先读 `../../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束;任务进度、完成比例、临时阻断和验收结论不写入本文件,也不作为业务契约。
- Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。
## 快速查询
| 目标 | 首选入口 |
|---|---|
| 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` |
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` |
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
| Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` |
| 收款/成本凭证 | `client/FinanceFileClient.java`;文件归属与下载规则见 `../file/AGENTS.md` |
| 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` |
| 数据库 | `src/main/resources/db/finance.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` |
| 测试 | `src/test/java/com/baiyue/finance/`;执行 `mvn -pl finance -am test` |
## 契约与边界
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
- 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。
- 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。
## 验证
- 文档或索引变更只做路径核对和 `git diff --check`;代码变更至少运行 `mvn -pl finance -am test`。
- 跨服务契约变更同时运行 Finance 与直接生产者/消费者测试。未经用户明确要求,不在本机启动 Java、Nacos 或数据库。
@@ -0,0 +1,34 @@
package com.baiyue.finance.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 java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@FeignClient(name = "firstleg-service", contextId = "financeFirstlegFeeSourceClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1")
public interface FirstlegFeeSourceClient {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long inboundId, @PathVariable long feeId);
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long containerId, @PathVariable long feeId);
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId,
String customerName, Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
String publicationStatus, boolean settlementIncluded,
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
String chargeUnit, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
}
@@ -3,33 +3,52 @@ package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "客户财务只读")
@RestController
@RequestMapping("/api/client/finance/v1")
public class ClientFinanceController {
private final FinanceService service;
public ClientFinanceController(FinanceService service) { this.service = service; }
private final FinanceQueryService queryService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService) {
this.service = service;
this.queryService = queryService;
}
@GetMapping("/bills")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询账单")
public ApiResponse<?> bills(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listClientBills(user.tenantId())); }
public ApiResponse<?> bills(@AuthenticationPrincipal AuthenticatedUser user) { return FinanceResponses.success(queryService.statements(user.tenantId(), true)); }
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('merchant:finance:statement:view')")
@Operation(summary = "客户只读查看账单详情")
public ApiResponse<Map<String, Object>> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return ApiResponse.success(service.clientBill(user.tenantId(), billId)); }
public ApiResponse<?> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return FinanceResponses.success(queryService.statement(user.tenantId(), billId, true)); }
@GetMapping("/receipts")
@PreAuthorize("hasAuthority('merchant:finance:statement:view')")
@Operation(summary = "客户只读查询收款记录")
public ApiResponse<?> receipts(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listReceipts(user.tenantId())); }
public ApiResponse<?> receipts(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.clientReceipts(user.tenantId()));
}
@GetMapping("/statements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询对账单")
public ApiResponse<?> statements(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询结算应收")
public ApiResponse<?> receivableSettlements(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.receivableSettlements(user.tenantId(), true));
}
}
@@ -0,0 +1,60 @@
package com.baiyue.finance.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.finance.service.FinanceEventException;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
import java.util.UUID;
/** Finance-owned error codes stay stable even when the common result catalog evolves. */
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "com.baiyue.finance.controller")
public class FinanceExceptionHandler {
@ExceptionHandler(FinanceEventException.class)
public ResponseEntity<ApiResponse<Void>> event(FinanceEventException error) {
return ResponseEntity.ok(response(error.code(), error.getMessage()));
}
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> business(BizException error) {
ResultCode result = error.getResultCode();
HttpStatus status = result == ResultCode.SERVICE_UNAVAILABLE ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK;
return ResponseEntity.status(status).body(response(financeCode(result), error.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> validation(IllegalArgumentException error) {
return ResponseEntity.ok(response("FIN_400", error.getMessage()));
}
private static String financeCode(ResultCode result) {
return switch (result) {
case NOT_FOUND -> "FIN_404";
case CONFLICT -> "FIN_409";
case SERVICE_UNAVAILABLE -> "FIN_503";
case VALIDATION_FAILED, BAD_REQUEST -> "FIN_400";
default -> "FIN_001";
};
}
private static ApiResponse<Void> response(String code, String message) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
String traceId = first(MDC.get("traceId"), MDC.get("trace_id"));
if (!StringUtils.hasText(traceId)) traceId = requestId;
return new ApiResponse<>(code, message, null, requestId, traceId, Instant.now());
}
private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second;
}
}
@@ -0,0 +1,23 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import org.slf4j.MDC;
import org.springframework.util.StringUtils;
import java.util.UUID;
final class FinanceResponses {
private FinanceResponses() {}
static <T> ApiResponse<T> success(T data) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
String traceId = first(MDC.get("traceId"), MDC.get("trace_id"));
if (!StringUtils.hasText(traceId)) traceId = requestId;
return ApiResponse.success(data, requestId, traceId);
}
private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second;
}
}
@@ -0,0 +1,36 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/internal/finance/v1/fee-source-events")
public class InternalFinanceFeeSourceController {
private final FinanceFeeSourceEventService service;
@Value("${baiyue.security.internal-service-token}")
private String expectedToken;
@PostMapping
public ApiResponse<Map<String, Long>> consume(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody FinanceFeeSourceEvent event) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new com.baiyue.finance.service.FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
}
return FinanceResponses.success(Map.of("feeSourceId", service.consume(event)));
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import io.swagger.v3.oas.annotations.Operation;
@@ -16,6 +17,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.beans.factory.annotation.Autowired;
@Tag(name = "人工财务台账")
@RestController
@@ -24,8 +26,58 @@ public class PlatformFinanceController {
private final FinanceService service;
private final FinanceFileClient fileClient;
private final FirstlegCostSnapshotClient costSnapshotClient;
private final FinanceQueryService queryService;
@Value("${baiyue.security.internal-service-token:}") private String fileToken;
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient, FirstlegCostSnapshotClient costSnapshotClient) { this.service = service; this.fileClient = fileClient; this.costSnapshotClient = costSnapshotClient; }
@Autowired
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient,
FirstlegCostSnapshotClient costSnapshotClient, FinanceQueryService queryService) {
this.service = service;
this.fileClient = fileClient;
this.costSnapshotClient = costSnapshotClient;
this.queryService = queryService;
}
/** Kept for direct unit tests of write behavior. */
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient,
FirstlegCostSnapshotClient costSnapshotClient) {
this(service, fileClient, costSnapshotClient, null);
}
@GetMapping("/customer-accounts/{customerId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户人工应收账户及流水")
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccount(customerId));
}
@GetMapping("/fee-pool")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool(@RequestParam long customerId,
@RequestParam(required = false) String direction) {
return FinanceResponses.success(queryService.feePool(customerId, direction));
}
@GetMapping("/statements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户对账单")
public ApiResponse<?> statements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.statements(customerId, false));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "结算单应收")
public ApiResponse<?> receivableSettlements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.receivableSettlements(customerId, false));
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:cost:view')")
@Operation(summary = "结算单应付")
public ApiResponse<?> payableSettlements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.payableSettlements(customerId));
}
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')")
@@ -33,60 +85,65 @@ public class PlatformFinanceController {
@RequestParam long customerId,
@RequestPart("file") MultipartFile file) {
if (customerId <= 0) throw new IllegalArgumentException("客户范围无效");
return fileClient.upload(fileToken, user.userId(), customerId, customerId, file);
var response = fileClient.upload(fileToken, user.userId(), customerId, customerId, file);
if (response == null || response.data() == null) throw new IllegalStateException("File服务未返回有效上传结果");
return FinanceResponses.success(response.data());
}
@GetMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('platform:finance:customer-terms:view')")
@Operation(summary = "查询客户账期")
public ApiResponse<Map<String, Object>> terms(@PathVariable long tenantId) { return ApiResponse.success(service.customerTerms(tenantId)); }
public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); }
@GetMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "查询平台账单")
public ApiResponse<?> bills(@RequestParam long customerId) { return ApiResponse.success(service.listBills(customerId)); }
public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); }
@PutMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('platform:finance:customer-terms:update')")
@BusinessAudit(event = "FINANCE_CUSTOMER_TERMS_UPDATE", resourceType = "CUSTOMER")
public ApiResponse<Map<String, Object>> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) {
return ApiResponse.success(service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key));
public ApiResponse<?> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) {
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
}
@PostMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
public ApiResponse<?> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList();
var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines);
return ApiResponse.success(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key));
return FinanceResponses.success(resourceView(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)));
}
@PostMapping("/bills/{billId}/issue")
@PreAuthorize("hasAuthority('platform:finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return ApiResponse.success(service.issueBillById(user.userId(), billId, request.expectedVersion(), key));
public ApiResponse<?> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return FinanceResponses.success(resourceView(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)));
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<Map<String, Object>> bill(@PathVariable long billId) { return ApiResponse.success(service.billById(billId)); }
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@GetMapping("/bills/{billId}/revisions")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
return ApiResponse.success(service.billRevisions(customerId, billId));
return FinanceResponses.success(queryService.billRevisions(customerId, billId));
}
@GetMapping("/receipts")
@PreAuthorize("hasAuthority('platform:finance:receipt:view')")
public ApiResponse<?> receipts(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listReceipts(customerId)); }
public ApiResponse<?> receipts(@RequestParam(required = false) Long customerId) {
if (customerId == null) throw new IllegalArgumentException("查询收款记录必须指定客户");
return FinanceResponses.success(queryService.platformReceipts(customerId));
}
@PostMapping("/receipts")
@PreAuthorize("hasAuthority('platform:finance:receipt:create')")
@BusinessAudit(event = "FINANCE_RECEIPT_CREATE", resourceType = "FINANCE_RECEIPT")
public ApiResponse<Map<String, Object>> receipt(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReceiptRequest request) {
public ApiResponse<?> receipt(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReceiptRequest request) {
if (request.amount() == null || request.amount().signum() <= 0) throw new IllegalArgumentException("收款金额必须大于0");
if (request.receivedDate() == null) throw new IllegalArgumentException("到账日期不能为空");
if (request.allocations() == null || request.allocations().isEmpty()) throw new IllegalArgumentException("收款至少需要分配一张账单");
@@ -95,17 +152,20 @@ public class PlatformFinanceController {
if (request.amount().compareTo(allocated) != 0) throw new IllegalArgumentException("收款金额必须等于分配金额合计");
var allocations = request.allocations().stream().map(a -> new FinanceService.ReceiptAllocation(a.billId(), a.amount())).toList();
var internal = new FinanceService.ReceiptRequest(null, 1L, request.receivedDate().atStartOfDay(), request.paymentMethod(), null, request.voucherFileIds(), request.remark(), allocations);
return ApiResponse.success(service.createReceipt(request.customerId(), user.userId(), internal, key));
return FinanceResponses.success(resourceView(service.createReceipt(request.customerId(), user.userId(), internal, key)));
}
@GetMapping("/cost-entries")
@PreAuthorize("hasAuthority('platform:finance:cost:view')")
public ApiResponse<?> costs(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listCosts(customerId)); }
public ApiResponse<?> costs(@RequestParam(required = false) Long customerId) {
if (customerId == null) throw new IllegalArgumentException("查询成本记录必须指定客户");
return FinanceResponses.success(queryService.payableSettlements(customerId));
}
@PostMapping("/cost-entries")
@PreAuthorize("hasAuthority('platform:finance:cost:create')")
@BusinessAudit(event = "FINANCE_COST_CREATE", resourceType = "FINANCE_COST")
public ApiResponse<Map<String, Object>> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) {
public ApiResponse<?> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) {
if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY");
var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null, request.containerId(), null, null, "MANUAL", request.expenseName(), request.amount(), request.voucherFileIds(), request.remark());
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
@@ -118,20 +178,42 @@ public class PlatformFinanceController {
}
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList();
}
return ApiResponse.success(service.createCost(request.customerId(), user.userId(), internal, volumes, key));
return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
}
@PutMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:bill:update')")
public ApiResponse<Map<String,Object>> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody UpdateBillRequest request) { return ApiResponse.success(service.updateBill(user.userId(), billId, request.customerId(), request.billDate(), request.lines(), request.expectedVersion(), key)); }
public ApiResponse<?> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody UpdateBillRequest request) { return FinanceResponses.success(resourceView(service.updateBill(user.userId(), billId, request.customerId(), request.billDate(), request.lines(), request.expectedVersion(), key))); }
@PostMapping("/bills/{billId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:bill:adjust')")
public ApiResponse<Map<String,Object>> adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody AdjustBillRequest request) { return ApiResponse.success(service.adjustBill(user.userId(), billId, request.lines(), request.expectedVersion(), request.reason(), key)); }
public ApiResponse<?> adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody AdjustBillRequest request) { return FinanceResponses.success(resourceView(service.adjustBill(user.userId(), billId, request.lines(), request.expectedVersion(), request.reason(), key))); }
@PostMapping("/bills/{billId}/void")
@PreAuthorize("hasAuthority('platform:finance:bill:void')")
public ApiResponse<Map<String,Object>> voidBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody ReasonRequest request) { return ApiResponse.success(service.voidBill(user.userId(), billId, request.expectedVersion(), request.reason(), key)); }
public ApiResponse<?> voidBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody ReasonRequest request) { return FinanceResponses.success(resourceView(service.voidBill(user.userId(), billId, request.expectedVersion(), request.reason(), key))); }
private static com.baiyue.finance.dto.FinanceReadModels.CustomerTerms termsView(long customerId, Map<String, Object> row) {
Object name = row.getOrDefault("customer_name_snapshot", row.get("customerNameSnapshot"));
Object days = row.getOrDefault("credit_days", row.get("creditDays"));
return new com.baiyue.finance.dto.FinanceReadModels.CustomerTerms(customerId, name == null ? null : name.toString(),
days == null ? 30 : ((Number) days).intValue(), String.valueOf(row.getOrDefault("status", "ACTIVE")));
}
private static com.baiyue.finance.dto.FinanceReadModels.ResourceResult resourceView(Map<String, Object> row) {
long id = number(row.get("id"), 0L).longValue();
Object no = first(row, "bill_no", "receipt_no", "cost_no");
Object version = row.get("version");
return new com.baiyue.finance.dto.FinanceReadModels.ResourceResult(id, no == null ? null : no.toString(),
String.valueOf(row.getOrDefault("status", "DRAFT")), version instanceof Number n ? n.intValue() : null);
}
private static Object first(Map<String, Object> row, String... keys) {
for (String key : keys) if (row.get(key) != null) return row.get(key);
return null;
}
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
public record TermsRequest(String customerName, Integer creditDays) {}
public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List<BillLineInput> lines) {}
@@ -0,0 +1,12 @@
package com.baiyue.finance.dto;
import java.time.LocalDateTime;
public record FinanceFeeSourceEvent(
String eventId, String eventType, int eventVersion, String producer,
LocalDateTime occurredAt, String tenantId, String aggregateType,
String aggregateId, int sequence, String idempotencyKey,
String payloadHash, Payload payload
) {
public record Payload(String inboundId, String containerId, String feeId, int feeVersion) {}
}
@@ -0,0 +1,146 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Stable read projections used by the Finance pages. */
public final class FinanceReadModels {
private FinanceReadModels() {}
public record CustomerAccount(
long customerId,
String currency,
String accountMode,
BigDecimal totalReceivable,
BigDecimal totalReceived,
BigDecimal outstandingAmount,
List<AccountEntry> entries) {}
public record AccountEntry(
String entryType,
long resourceId,
String resourceNo,
LocalDateTime occurredAt,
BigDecimal debitAmount,
BigDecimal creditAmount,
String currency,
String status) {}
public record FeePoolItem(
String poolItemId,
String direction,
String sourceType,
long sourceId,
int sourceVersion,
Long orderId,
Long inboundId,
Long containerId,
String feeItemName,
BigDecimal amount,
String currency,
String status,
LocalDateTime createdAt) {}
public record Statement(
long id,
long customerId,
String billNo,
String customerName,
LocalDate billDate,
LocalDate dueDate,
BigDecimal totalAmount,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
String currency,
String status,
int version) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
public record StatementLine(
long id,
String sourceType,
long sourceId,
int sourceVersion,
Long orderId,
Long inboundId,
String feeItemCode,
String feeItemName,
BigDecimal amount,
String currency) {}
public record ReceivableSettlement(
long id,
long customerId,
String settlementNo,
LocalDate billDate,
LocalDate dueDate,
BigDecimal receivableAmount,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
String currency,
String status,
int version) {}
public record PayableSettlement(
long id,
long customerId,
String settlementNo,
String costType,
Long orderId,
Long inboundId,
Long containerId,
String supplierName,
String feeItemName,
BigDecimal payableAmount,
BigDecimal paidAmount,
BigDecimal outstandingAmount,
String currency,
String status,
LocalDateTime createdAt,
int version) {}
/** Client-safe receipt view. Voucher and operator/internal fields are deliberately absent. */
public record ClientReceipt(
long id,
String receiptNo,
BigDecimal receivedAmount,
LocalDateTime receivedAt,
String paymentMethod,
String currency,
String status,
List<ClientReceiptAllocation> allocations) {}
public record ClientReceiptAllocation(long billId, String billNo, BigDecimal appliedAmount) {}
public record PlatformReceipt(
long id,
long customerId,
String receiptNo,
BigDecimal receivedAmount,
LocalDateTime receivedAt,
String paymentMethod,
String voucherNo,
List<String> voucherFileIds,
String currency,
String status,
String remark,
int version,
List<ClientReceiptAllocation> allocations) {}
public record BillRevision(
long id,
long billId,
int version,
String reason,
String billSnapshot,
String lineSnapshot,
long createdBy,
LocalDateTime createdAt) {}
public record CustomerTerms(long customerId, String customerName, int creditDays, String status) {}
public record ResourceResult(long id, String resourceNo, String status, Integer version) {}
}
@@ -0,0 +1,12 @@
package com.baiyue.finance.service;
public final class FinanceEventException extends RuntimeException {
private final String code;
public FinanceEventException(String code, String message) {
super(message);
this.code = code;
}
public String code() { return code; }
}
@@ -0,0 +1,153 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceEventService {
private static final Set<String> EVENT_TYPES = Set.of(
"FIRSTLEG_FEE_PUBLISHED", "FIRSTLEG_FEE_ADJUSTED", "FIRSTLEG_FEE_VOIDED");
private final JdbcTemplate jdbc;
private final FirstlegFeeSourceClient firstleg;
private final ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@Transactional
public long consume(FinanceFeeSourceEvent event) {
validateEnvelope(event);
String eventJson = json(event);
String requestHash = sha256(eventJson);
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT payload_hash,status,result_resource_id FROM fin_inbox_event WHERE source_system=? AND event_id=? FOR UPDATE",
"FIRSTLEG", event.eventId());
if (!existing.isEmpty()) {
Map<String, Object> row = existing.get(0);
if (!requestHash.equals(row.get("payload_hash"))) {
throw error("FIN-EVT-002", "同一事件ID对应不同请求内容");
}
if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id) {
return id.longValue();
}
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败");
}
jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')",
event.eventId(), "FIRSTLEG", event.eventType(), eventJson, requestHash);
FirstlegFeeSourceClient.Snapshot snapshot = loadSnapshot(event);
validateSnapshot(event, snapshot);
long resourceId = applyEvent(event, snapshot);
jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?",
resourceId, "FIRSTLEG", event.eventId());
return resourceId;
}
private long applyEvent(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot snapshot) {
List<Map<String, Object>> sameVersion = jdbc.queryForList(
"SELECT id,source_hash,status FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=? FOR UPDATE",
snapshot.feeId(), snapshot.feeVersion());
if (!sameVersion.isEmpty()) {
Map<String, Object> row = sameVersion.get(0);
if (!snapshot.sourceHash().equals(row.get("source_hash"))) {
throw error("FIN-EVT-004", "同一来源费用版本快照不一致");
}
long id = ((Number) row.get("id")).longValue();
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", id);
}
return id;
}
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
}
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
snapshot.feeId());
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(),
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
snapshot.sourceHash(), json(snapshot));
return jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?",
Long.class, snapshot.feeId(), snapshot.feeVersion());
}
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
FinanceFeeSourceEvent.Payload payload = event.payload();
var response = payload.inboundId() != null
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), positive(payload.feeId(), "feeId"))
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"), positive(payload.feeId(), "feeId"));
if (response == null || response.data() == null || !"200".equals(response.code())) {
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
}
return response.data();
}
private void validateEnvelope(FinanceFeeSourceEvent event) {
if (event == null || blank(event.eventId()) || !EVENT_TYPES.contains(event.eventType())
|| !"firstleg-service".equals(event.producer()) || event.payload() == null
|| blank(event.tenantId()) || blank(event.payload().feeId())) {
throw error("FIN-EVT-001", "费用来源事件信封无效");
}
boolean inbound = event.payload().inboundId() != null;
boolean container = event.payload().containerId() != null;
if (inbound == container || !sha256(json(event.payload())).equals(event.payloadHash())) {
throw error("FIN-EVT-001", "费用来源事件载荷无效");
}
}
private void validateSnapshot(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot value) {
long tenantId = positive(event.tenantId(), "tenantId");
long feeId = positive(event.payload().feeId(), "feeId");
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
if (!"APPROVED".equals(value.reviewStatus()) || !value.settlementIncluded()) throw error("FIN-EVT-009", "费用未审核通过或未参与结算");
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
throw error("FIN-EVT-015", "费用来源尚未发布");
}
if (!"CNY".equals(value.currency()) || value.totalAmount() == null || value.totalAmount().signum() <= 0) throw error("FIN-EVT-010", "费用金额或币种无效");
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); }
}
private static long positive(String value, String field) {
try {
long parsed = Long.parseLong(value);
if (parsed > 0) return parsed;
} catch (RuntimeException ignored) { }
throw error("FIN-EVT-001", field + "无效");
}
private static String sha256(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
@@ -0,0 +1,197 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.dto.FinanceReadModels.AccountEntry;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.dto.FinanceReadModels.BillRevision;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccount;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.dto.FinanceReadModels.PayableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.PlatformReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ReceivableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.Statement;
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/** Typed query facade for the five Finance pages and client-safe read APIs. */
@Service
public class FinanceQueryService {
private static final String CNY = "CNY";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
public CustomerAccount customerAccount(long customerId) {
requireCustomer(customerId);
BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
BigDecimal received = amount("SELECT COALESCE(SUM(received_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
List<AccountEntry> entries = new ArrayList<>();
entries.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,currency_code,status FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("bill_no"), time(rs, "created_at"),
rs.getBigDecimal("total_amount"), BigDecimal.ZERO, rs.getString("currency_code"), rs.getString("status")), customerId));
entries.addAll(jdbc.query("SELECT id,receipt_no,received_at,received_amount,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
new AccountEntry("RECEIPT", rs.getLong("id"), rs.getString("receipt_no"), time(rs, "received_at"),
BigDecimal.ZERO, rs.getBigDecimal("received_amount"), rs.getString("currency_code"), rs.getString("status")), customerId));
entries.sort(Comparator.comparing(AccountEntry::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries));
}
public List<FeePoolItem> feePool(long customerId, String direction) {
requireCustomer(customerId);
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
List<FeePoolItem> result = new ArrayList<>();
String sourceSql = "SELECT id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE customer_id=? AND status='AVAILABLE'";
Object[] sourceArgs;
if ("ALL".equals(normalized)) {
sourceSql += " ORDER BY created_at DESC,id DESC";
sourceArgs = new Object[]{customerId};
} else {
sourceSql += " AND direction=? ORDER BY created_at DESC,id DESC";
sourceArgs = new Object[]{customerId, normalized};
}
result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> {
String rowDirection = rs.getString("direction");
return new FeePoolItem(rowDirection + ":" + rs.getLong("id"), rowDirection,
rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"),
rs.getString("status"), time(rs, "created_at"));
}, sourceArgs));
if (!"RECEIVABLE".equals(normalized)) {
result.addAll(jdbc.query("SELECT id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC", (rs, rowNum) ->
new FeePoolItem("PAYABLE:" + rs.getLong("id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1,
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), customerId));
}
result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder())));
return List.copyOf(result);
}
public List<Statement> statements(long customerId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
return jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=?" + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId);
}
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
List<StatementLine> lines = jdbc.query("SELECT id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",
(rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), rs.getString("fee_item_code"), rs.getString("fee_item_name"),
rs.getBigDecimal("amount"), rs.getString("currency_code")), customerId, billId);
return new StatementDetail(rows.get(0), lines);
}
public StatementDetail platformStatement(long billId) {
Long customerId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId);
if (customerId == null) throw new BizException(ResultCode.NOT_FOUND, "账单不存在");
return statement(customerId, billId, false);
}
public List<BillRevision> billRevisions(long customerId, long billId) {
statement(customerId, billId, false);
return jdbc.query("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC",
(rs, rowNum) -> new BillRevision(rs.getLong("id"), rs.getLong("bill_id"), rs.getInt("version"), rs.getString("reason"),
rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"), time(rs, "created_at")), customerId, billId);
}
public List<ReceivableSettlement> receivableSettlements(long customerId, boolean clientVisibleOnly) {
return statements(customerId, clientVisibleOnly).stream().map(row -> new ReceivableSettlement(
row.id(), row.customerId(), row.billNo(), row.billDate(), row.dueDate(), row.totalAmount(), row.receivedAmount(),
row.outstandingAmount(), row.currency(), row.status(), row.version())).toList();
}
public List<PayableSettlement> payableSettlements(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC",
(rs, rowNum) -> {
BigDecimal amount = rs.getBigDecimal("cost_amount");
return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"),
rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"),
time(rs, "created_at"), rs.getInt("version"));
}, customerId);
}
public List<ClientReceipt> clientReceipts(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,receipt_no,received_amount,received_at,payment_method,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED' ORDER BY received_at DESC,id DESC",
(rs, rowNum) -> new ClientReceipt(rs.getLong("id"), rs.getString("receipt_no"), rs.getBigDecimal("received_amount"),
time(rs, "received_at"), rs.getString("payment_method"), rs.getString("currency_code"), rs.getString("status"), allocations(customerId, rs.getLong("id"))), customerId);
}
public List<PlatformReceipt> platformReceipts(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,tenant_id,receipt_no,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,currency_code,status,remark,version FROM fin_receipt WHERE tenant_id=? ORDER BY received_at DESC,id DESC",
(rs, rowNum) -> new PlatformReceipt(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("receipt_no"), rs.getBigDecimal("received_amount"),
time(rs, "received_at"), rs.getString("payment_method"), rs.getString("voucher_no"), fileIds(rs.getString("voucher_file_ids")),
rs.getString("currency_code"), rs.getString("status"), rs.getString("remark"), rs.getInt("version"), allocations(customerId, rs.getLong("id"))), customerId);
}
private List<String> fileIds(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception error) {
throw new IllegalStateException("财务凭证文件字段损坏", error);
}
}
private List<ClientReceiptAllocation> allocations(long customerId, long receiptId) {
return jdbc.query("SELECT rb.bill_id,b.bill_no,rb.applied_amount FROM fin_receipt_bill rb JOIN fin_bill b ON b.id=rb.bill_id AND b.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.receipt_id=? ORDER BY rb.id",
(rs, rowNum) -> new ClientReceiptAllocation(rs.getLong("bill_id"), rs.getString("bill_no"), rs.getBigDecimal("applied_amount")), customerId, receiptId);
}
private BigDecimal amount(String sql, long customerId) {
BigDecimal value = jdbc.queryForObject(sql, BigDecimal.class, customerId);
return value == null ? BigDecimal.ZERO : value;
}
private static Long nullableLong(ResultSet rs, String column) throws SQLException {
long value = rs.getLong(column);
return rs.wasNull() ? null : value;
}
private static LocalDateTime time(ResultSet rs, String column) throws SQLException {
Timestamp timestamp = rs.getTimestamp(column);
return timestamp == null ? null : timestamp.toLocalDateTime();
}
private static void requireCustomer(long customerId) {
if (customerId <= 0) throw bad("客户范围无效");
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
}
@@ -194,8 +194,8 @@ public class FinanceService {
}
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
if (fileIds == null || fileIds.isEmpty()) throw bad("收款凭证不能为空");
if (fileClient == null) return;
if (fileIds == null || fileIds.isEmpty()) throw bad("财务凭证不能为空");
if (fileClient == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务校验能力不可用");
for (String raw : fileIds) {
long id; try { id = Long.parseLong(raw); } catch (Exception ex) { throw bad("凭证文件ID无效"); }
var response = fileClient.metadata(fileToken, tenantId, tenantId, id);
@@ -298,12 +298,23 @@ public class FinanceService {
private Long reserve(long tenantId, long operatorId, String operation, String key, Object request) {
if (!StringUtils.hasText(key) || key.length() > 128) throw bad("必须提供有效的 Idempotency-Key");
String hash = hash(request);
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key);
String hash = hash(new IdempotencyRequest(httpMethod(operation), requestPath(operation), request));
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key);
if (!rows.isEmpty()) {
Map<String,Object> row = rows.get(0);
if (!hash.equals(row.get("request_hash"))) throw new BizException(ResultCode.CONFLICT, "Idempotency-Key已用于其他请求");
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
if ("FAILED".equals(row.get("status"))) {
int recovered = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
tenantId, operatorId, operation, key);
if (recovered == 1) return null;
}
LocalDateTime updatedAt = idempotencyUpdatedAt(row.get("updated_at"));
if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) {
int recovered = jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
tenantId, operatorId, operation, key, updatedAt);
if (recovered == 1) return null;
}
throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中");
}
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", tenantId, operatorId, operation, key, hash);
@@ -315,6 +326,30 @@ public class FinanceService {
}
private static String temporaryNo(String prefix) { return prefix + "-PENDING-" + java.util.UUID.randomUUID(); }
private static String stableNo(String prefix, long id) { return prefix + "-" + String.format(java.util.Locale.ROOT, "%020d", id); }
private static LocalDateTime idempotencyUpdatedAt(Object value) {
if (value instanceof LocalDateTime dateTime) return dateTime;
if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime();
return null;
}
private static String httpMethod(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE", "BILL_UPDATE" -> "PUT";
default -> "POST";
};
}
private static String requestPath(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
case "BILL_CREATE" -> "/api/platform/finance/v1/bills";
case "BILL_ISSUE" -> "/api/platform/finance/v1/bills/{billId}/issue";
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
case "BILL_ADJUST" -> "/api/platform/finance/v1/bills/{billId}/adjust";
case "BILL_VOID" -> "/api/platform/finance/v1/bills/{billId}/void";
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
};
}
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
@@ -357,4 +392,5 @@ public class FinanceService {
public record ContainerOrderVolume(long orderId, Long inboundId, long containerId, long customerId, BigDecimal volumeCbm) {}
public record CostAllocation(ContainerOrderVolume order, BigDecimal totalVolumeCbm, BigDecimal rate,
BigDecimal amount, boolean roundingAdjustment) {}
private record IdempotencyRequest(String method, String path, Object body) {}
}
@@ -0,0 +1,88 @@
-- Firstleg费用来源事件消费模型存量升级(MySQL 8,可重复执行)
USE baiyue_finance;
DROP PROCEDURE IF EXISTS finance_fee_source_add_column;
DELIMITER $$
CREATE PROCEDURE finance_fee_source_add_column(
IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = target_table AND column_name = target_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL finance_fee_source_add_column('fin_fee_source', 'source_type',
"VARCHAR(32) NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用' AFTER customer_id");
CALL finance_fee_source_add_column('fin_fee_source', 'source_no',
"VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空' AFTER source_type");
CALL finance_fee_source_add_column('fin_fee_source', 'direction',
"VARCHAR(16) NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付' AFTER source_no");
CALL finance_fee_source_add_column('fin_fee_source', 'container_id',
"BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空' AFTER inbound_id");
CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code',
"VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id");
CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
"CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
"CHAR(64) NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测' AFTER payload");
CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id',
"BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID' AFTER payload_hash");
CALL finance_fee_source_add_column('fin_inbox_event', 'error_code',
"VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码' AFTER result_resource_id");
DROP PROCEDURE IF EXISTS finance_fee_source_add_column;
-- 产品确认无历史财务数据;以下兼容值仅用于空环境重复迁移时保证结构收敛。
UPDATE fin_fee_source
SET source_type = COALESCE(source_type, 'INBOUND_FEE'),
direction = COALESCE(direction, 'RECEIVABLE'),
fee_item_code = COALESCE(fee_item_code, 'LEGACY'),
source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)),
source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)),
updated_at = COALESCE(updated_at, created_at);
UPDATE fin_inbox_event
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
ALTER TABLE fin_fee_source
MODIFY COLUMN source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用',
MODIFY COLUMN direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
MODIFY COLUMN fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照',
MODIFY COLUMN source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新';
ALTER TABLE fin_inbox_event
MODIFY COLUMN payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测',
MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败';
DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema;
DELIMITER $$
CREATE PROCEDURE finance_fee_source_assert_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
'source_hash','source_snapshot','updated_at')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_inbox_event'
AND column_name IN ('payload_hash','result_resource_id','error_code')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:Inbox幂等字段不完整';
END IF;
END$$
DELIMITER ;
CALL finance_fee_source_assert_schema();
DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema;
+183 -172
View File
@@ -3,18 +3,26 @@ CREATE DATABASE IF NOT EXISTS baiyue_finance
USE baiyue_finance;
CREATE TABLE IF NOT EXISTS fin_fee_source (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
source_system VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG',
source_fee_id BIGINT UNSIGNED NOT NULL,
source_fee_version INT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
fee_item_name VARCHAR(200) NOT NULL,
amount DECIMAL(20,6) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
source_system VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '费用来源系统,当前FIRSTLEG表示头程服务',
source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '来源系统费用记录ID',
source_fee_version INT UNSIGNED NOT NULL COMMENT '来源费用版本号,用于固化不可变来源版本',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '费用来源所属客户ID',
source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用',
source_no VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空',
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空',
inbound_id BIGINT UNSIGNED NULL COMMENT '来源入库单ID,可为空',
container_id BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空',
fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照',
amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version),
KEY idx_fin_fee_source_customer (customer_id, status),
@@ -23,43 +31,46 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
) ENGINE=InnoDB COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细';
CREATE TABLE IF NOT EXISTS fin_inbox_event (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id VARCHAR(128) NOT NULL,
source_system VARCHAR(32) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED',
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
processed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
event_id VARCHAR(128) NOT NULL COMMENT '来源系统事件唯一ID',
source_system VARCHAR(32) NOT NULL COMMENT '事件来源系统标识',
event_type VARCHAR(64) NOT NULL COMMENT '来源事件类型',
payload JSON NOT NULL COMMENT '来源事件原始JSON载荷快照',
payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测',
result_resource_id BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID',
error_code VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码',
status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败',
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件接收时间',
processed_at DATETIME(3) NULL COMMENT '事件处理完成时间,未完成时为空',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_inbox_event (source_system, event_id)
) ENGINE=InnoDB COMMENT='Firstleg费用事件幂等接收记录';
CREATE TABLE IF NOT EXISTS fin_subject (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
subject_code VARCHAR(64) NOT NULL,
subject_name VARCHAR(200) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
subject_code VARCHAR(64) NOT NULL COMMENT '平台财务主体唯一编码',
subject_name VARCHAR(200) NOT NULL COMMENT '平台收付款及核算主体名称',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '财务主体状态,ACTIVE表示启用',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID,0表示系统初始化',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改操作人ID,0表示系统初始化',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_subject_code (subject_code)
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name_snapshot VARCHAR(200) NOT NULL,
credit_days INT UNSIGNED NOT NULL DEFAULT 30 COMMENT '默认30个自然日',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '客户名称快照,客户主数据后续变更不追改',
credit_days INT UNSIGNED NOT NULL DEFAULT 30 COMMENT '客户账期自然日数,默认30天',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '客户结算配置状态,ACTIVE表示启用',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_customer_profile (tenant_id),
CONSTRAINT chk_fin_customer_profile_tenant CHECK (tenant_id > 0),
@@ -67,29 +78,29 @@ CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
) ENGINE=InnoDB COMMENT='客户结算配置;一客户一个结算对象';
CREATE TABLE IF NOT EXISTS fin_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_no VARCHAR(64) NOT NULL,
subject_id BIGINT UNSIGNED NOT NULL,
customer_name_snapshot VARCHAR(200) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
bill_date DATE NOT NULL,
due_date DATE NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/ISSUED/PARTIALLY_PAID/PAID/VOIDED',
total_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
received_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
outstanding_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
remark VARCHAR(500) NULL,
issued_at DATETIME(3) NULL,
issued_by BIGINT UNSIGNED NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
bill_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一账单编号',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体ID',
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '出账时客户名称快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
bill_date DATE NOT NULL COMMENT '账单日期',
due_date DATE NOT NULL COMMENT '到期日期,由账单日期加客户账期计算',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '账单状态:DRAFT草稿、ISSUED已出账、PARTIALLY_PAID部分收款、PAID已收清、VOIDED已作废',
total_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单全部明细总金额,精度6位小数',
received_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单累计已收金额,精度6位小数',
outstanding_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单未收余额,精度6位小数',
remark VARCHAR(500) NULL COMMENT '平台账单内部备注',
issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空',
issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空',
voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空',
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空',
void_reason VARCHAR(500) NULL COMMENT '账单作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_no (tenant_id, bill_no),
KEY idx_fin_bill_customer_status_date (tenant_id, status, bill_date),
@@ -99,20 +110,20 @@ CREATE TABLE IF NOT EXISTS fin_bill (
) ENGINE=InnoDB COMMENT='客户CNY账单;首期账单即应收台账';
CREATE TABLE IF NOT EXISTS fin_bill_line (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_FEE',
source_id BIGINT UNSIGNED NOT NULL,
source_version INT UNSIGNED NOT NULL,
source_no VARCHAR(64) NULL,
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
fee_item_code VARCHAR(64) NOT NULL,
fee_item_name VARCHAR(200) NOT NULL,
amount DECIMAL(20,6) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
bill_id BIGINT UNSIGNED NOT NULL COMMENT '所属账单ID',
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_FEE' COMMENT '费用来源类型,FIRSTLEG_FEE表示头程费用',
source_id BIGINT UNSIGNED NOT NULL COMMENT '来源费用ID',
source_version INT UNSIGNED NOT NULL COMMENT '出账固化的来源费用版本号',
source_no VARCHAR(64) NULL COMMENT '来源费用单号快照,可为空',
order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空',
inbound_id BIGINT UNSIGNED NULL COMMENT '来源入库单ID,可为空',
fee_item_code VARCHAR(64) NOT NULL COMMENT '出账时费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '出账时费用项目名称快照',
amount DECIMAL(20,6) NOT NULL COMMENT '账单明细金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version),
KEY idx_fin_bill_line_bill (tenant_id, bill_id),
@@ -122,26 +133,26 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
) ENGINE=InnoDB COMMENT='账单费用来源快照明细';
CREATE TABLE IF NOT EXISTS fin_receipt (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
receipt_no VARCHAR(64) NOT NULL,
subject_id BIGINT UNSIGNED NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
received_amount DECIMAL(20,6) NOT NULL,
received_at DATETIME(3) NOT NULL,
payment_method VARCHAR(32) NULL,
voucher_no VARCHAR(128) NULL,
voucher_file_ids JSON NOT NULL COMMENT 'File服务文件ID字符串数组',
status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT 'CONFIRMED/VOIDED',
remark VARCHAR(500) NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
receipt_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收款编号',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '实际收款的平台财务主体ID',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
received_amount DECIMAL(20,6) NOT NULL COMMENT '本次实际到账金额,精度6位小数',
received_at DATETIME(3) NOT NULL COMMENT '实际到账时间',
payment_method VARCHAR(32) NULL COMMENT '收款方式编码',
voucher_no VARCHAR(128) NULL COMMENT '银行流水号或线下收款凭证号',
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT '收款状态:CONFIRMED已确认、VOIDED已作废',
remark VARCHAR(500) NULL COMMENT '平台收款内部备注',
voided_at DATETIME(3) NULL COMMENT '收款作废时间',
voided_by BIGINT UNSIGNED NULL COMMENT '收款作废操作人ID',
void_reason VARCHAR(500) NULL COMMENT '收款作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_receipt_no (tenant_id, receipt_no),
KEY idx_fin_receipt_customer_time (tenant_id, received_at),
@@ -151,13 +162,13 @@ CREATE TABLE IF NOT EXISTS fin_receipt (
) ENGINE=InnoDB COMMENT='平台财务人工登记收款及凭证';
CREATE TABLE IF NOT EXISTS fin_receipt_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
receipt_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
applied_amount DECIMAL(20,6) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
receipt_id BIGINT UNSIGNED NOT NULL COMMENT '收款记录ID',
bill_id BIGINT UNSIGNED NOT NULL COMMENT '被分配收款的账单ID',
applied_amount DECIMAL(20,6) NOT NULL COMMENT '本笔收款分配到该账单的金额,精度6位小数',
created_by BIGINT UNSIGNED NOT NULL COMMENT '分配操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '收款分配创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_receipt_bill_pair (receipt_id, bill_id),
KEY idx_fin_receipt_bill_bill (tenant_id, bill_id),
@@ -166,32 +177,32 @@ CREATE TABLE IF NOT EXISTS fin_receipt_bill (
) ENGINE=InnoDB COMMENT='人工收款对应账单金额;UI不使用复杂核销术语';
CREATE TABLE IF NOT EXISTS fin_cost (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
cost_no VARCHAR(64) NOT NULL,
cost_type VARCHAR(24) NOT NULL COMMENT 'SINGLE_ORDER/CONTAINER_SHARED',
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
container_id BIGINT UNSIGNED NULL,
supplier_id BIGINT UNSIGNED NULL,
supplier_name_snapshot VARCHAR(200) NULL,
fee_item_code VARCHAR(64) NOT NULL,
fee_item_name VARCHAR(200) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
cost_amount DECIMAL(20,6) NOT NULL,
voucher_file_ids JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/CONFIRMED/VOIDED',
remark VARCHAR(500) NULL,
confirmed_at DATETIME(3) NULL,
confirmed_by BIGINT UNSIGNED NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
cost_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一成本编号',
cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票成本、CONTAINER_SHARED整柜公共成本',
order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填',
inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空',
container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填',
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse公共往来单位或供应商引用ID',
supplier_name_snapshot VARCHAR(200) NULL COMMENT '登记成本时供应商或成本对象名称快照',
fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数',
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废',
remark VARCHAR(500) NULL COMMENT '平台成本内部备注',
confirmed_at DATETIME(3) NULL COMMENT '成本确认时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '成本确认操作人ID',
voided_at DATETIME(3) NULL COMMENT '成本作废时间',
voided_by BIGINT UNSIGNED NULL COMMENT '成本作废操作人ID',
void_reason VARCHAR(500) NULL COMMENT '成本作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no),
KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id),
@@ -206,17 +217,17 @@ CREATE TABLE IF NOT EXISTS fin_cost (
) ENGINE=InnoDB COMMENT='人工成本;单票直接归属,整柜按体积分摊';
CREATE TABLE IF NOT EXISTS fin_cost_allocation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
cost_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NULL,
volume_cbm DECIMAL(20,6) NOT NULL,
total_volume_cbm DECIMAL(20,6) NOT NULL,
allocation_rate DECIMAL(20,10) NOT NULL,
allocated_amount DECIMAL(20,6) NOT NULL,
rounding_adjustment TINYINT NOT NULL DEFAULT 0 COMMENT '1表示尾差归集行',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
cost_id BIGINT UNSIGNED NOT NULL COMMENT '整柜公共成本ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '参与分摊的头程订单ID',
inbound_id BIGINT UNSIGNED NULL COMMENT '参与分摊订单关联的入库单ID,可为空',
volume_cbm DECIMAL(20,6) NOT NULL COMMENT '该订单分摊时有效体积快照,单位CBM、精度6位',
total_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '柜内有效订单总体积快照,单位CBM、精度6位',
allocation_rate DECIMAL(20,10) NOT NULL COMMENT '订单体积占总体积的分摊比例,精度10位',
allocated_amount DECIMAL(20,6) NOT NULL COMMENT '分摊到该订单的成本金额,精度6位',
rounding_adjustment TINYINT NOT NULL DEFAULT 0 COMMENT '尾差归集标记:0未归集、1该行为实际尾差归集行',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变分摊快照创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_allocation_order (cost_id, order_id),
KEY idx_fin_cost_allocation_query (tenant_id, order_id),
@@ -225,50 +236,50 @@ CREATE TABLE IF NOT EXISTS fin_cost_allocation (
) ENGINE=InnoDB COMMENT='整柜成本按有效体积分摊的可审计结果';
CREATE TABLE IF NOT EXISTS fin_bill_revision (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
version INT UNSIGNED NOT NULL,
reason VARCHAR(500) NULL,
bill_snapshot JSON NOT NULL,
line_snapshot JSON NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
bill_id BIGINT UNSIGNED NOT NULL COMMENT '被调整账单ID',
version INT UNSIGNED NOT NULL COMMENT '调整前账单乐观锁版本号',
reason VARCHAR(500) NULL COMMENT '本次账单调整原因',
bill_snapshot JSON NOT NULL COMMENT '调整前账单主记录不可变JSON快照',
line_snapshot JSON NOT NULL COMMENT '调整前全部账单明细不可变JSON数组快照',
created_by BIGINT UNSIGNED NOT NULL COMMENT '执行调整的操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '修订快照创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_revision (bill_id, version),
KEY idx_fin_bill_revision_tenant (tenant_id, bill_id)
) ENGINE=InnoDB COMMENT='出账后调整前的不可变账单及明细版本快照';
CREATE TABLE IF NOT EXISTS fin_idempotency_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING/SUCCEEDED/FAILED',
result_resource_id BIGINT UNSIGNED NULL,
error_code VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起幂等写操作的操作人ID',
operation VARCHAR(64) NOT NULL COMMENT '幂等隔离的财务操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方提供的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求的SHA-256十六进制哈希',
status VARCHAR(16) NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED成功、FAILED失败',
result_resource_id BIGINT UNSIGNED NULL COMMENT '成功操作生成或更新的业务资源ID',
error_code VARCHAR(64) NULL COMMENT '失败操作的稳定业务错误码',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等请求首次登记时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '幂等处理状态最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
CREATE TABLE IF NOT EXISTS fin_audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
reason VARCHAR(500) NULL,
before_data JSON NULL,
after_data JSON NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name_snapshot VARCHAR(128) NOT NULL,
request_id VARCHAR(128) NULL,
trace_id VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
resource_type VARCHAR(32) NOT NULL COMMENT '被审计财务资源类型',
resource_id BIGINT UNSIGNED NOT NULL COMMENT '被审计财务资源ID',
action VARCHAR(64) NOT NULL COMMENT '审计业务动作类型',
reason VARCHAR(500) NULL COMMENT '操作原因或说明',
before_data JSON NULL COMMENT '操作前业务数据JSON快照',
after_data JSON NULL COMMENT '操作后业务数据JSON快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name_snapshot VARCHAR(128) NOT NULL COMMENT '操作发生时的操作人名称快照',
request_id VARCHAR(128) NULL COMMENT '入口请求ID,用于问题定位',
trace_id VARCHAR(128) NULL COMMENT '分布式调用链追踪ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变审计记录创建时间',
PRIMARY KEY (id),
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
KEY idx_fin_audit_operator (operator_id, created_at)
@@ -19,7 +19,7 @@ class FinanceContractTest {
assertTrue(sql.contains("currency_code CHECK (currency_code = 'CNY')") || sql.contains("currency_code = 'CNY'"));
assertTrue(sql.contains("fin_fee_source") && sql.contains("fin_inbox_event"));
assertTrue(sql.contains("fin_bill_revision"));
assertTrue(sql.contains("PROCESSING/SUCCEEDED/FAILED"));
assertTrue(sql.contains("PROCESSING") && sql.contains("SUCCEEDED") && sql.contains("FAILED"));
String service = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinanceService.java"));
assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo"));
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
@@ -0,0 +1,31 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.controller.FinanceExceptionHandler;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceExceptionHandlerTest {
private final FinanceExceptionHandler handler = new FinanceExceptionHandler();
@Test
void returnsStableFinanceConflictCodeAndTraceFields() {
var response = handler.business(new BizException(ResultCode.CONFLICT, "幂等键冲突"));
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo("FIN_409");
assertThat(response.getBody().requestId()).isNotBlank();
assertThat(response.getBody().traceId()).isNotBlank();
}
@Test
void dependencyFailureKeepsInfrastructureHttpStatus() {
var response = handler.business(new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务不可用"));
assertThat(response.getStatusCode().value()).isEqualTo(503);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo("FIN_503");
}
}
@@ -0,0 +1,67 @@
package com.baiyue.finance;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.service.FinanceQueryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeePoolDirectionBehaviorTest {
@Test
void receivablePoolNeverContainsPayableSource() {
DirectionJdbc jdbc = new DirectionJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
List<FeePoolItem> result = service.feePool(7L, "RECEIVABLE");
assertThat(result).isNotEmpty().allMatch(item -> "RECEIVABLE".equals(item.direction()));
assertThat(jdbc.sourceDirections).containsExactly("RECEIVABLE");
assertThat(jdbc.costQueryCount).isZero();
}
@Test
void payablePoolNeverContainsReceivableSource() {
DirectionJdbc jdbc = new DirectionJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
List<FeePoolItem> result = service.feePool(7L, "PAYABLE");
assertThat(result).isNotEmpty().allMatch(item -> "PAYABLE".equals(item.direction()));
assertThat(jdbc.sourceDirections).containsExactly("PAYABLE");
assertThat(jdbc.costQueryCount).isEqualTo(1);
}
private static final class DirectionJdbc extends JdbcTemplate {
private final List<String> sourceDirections = new ArrayList<>();
private int costQueryCount;
@Override
@SuppressWarnings("unchecked")
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
if (sql.contains("FROM fin_fee_source")) {
String direction = String.valueOf(args[1]);
sourceDirections.add(direction);
return (List<T>) List.of(item(direction, "FIRSTLEG_FEE", 1L));
}
if (sql.contains("FROM fin_cost")) {
costQueryCount++;
return (List<T>) List.of(item("PAYABLE", "MANUAL_COST", 2L));
}
return List.of();
}
private static FeePoolItem item(String direction, String sourceType, long sourceId) {
return new FeePoolItem(direction + ":" + sourceId, direction, sourceType, sourceId, 1,
11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY", "AVAILABLE",
LocalDateTime.of(2026, 8, 11, 1, 0));
}
}
}
@@ -0,0 +1,197 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FinanceFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper);
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
}
@Test
void importsApprovedSettlementInboundFee() {
FinanceFeeSourceEvent event = inboundEvent("published-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
when(firstleg.inbound("test-token", 21, 31)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).inbound("test-token", 21, 31);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void importsApprovedSettlementContainerFeeWithCustomerOwnership() {
FinanceFeeSourceEvent event = containerEvent("container-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).container("test-token", 41, 31);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
}
@Test
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
givenNewEvent();
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void voidMarksExistingVersionWithoutDeletingHistory() {
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
assertThat(service.consume(event)).isEqualTo(88L);
verify(jdbc).update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", 88L);
verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class));
}
@Test
void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception {
FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
String requestHash = sha256(mapper.writeValueAsString(event));
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("same-1")))
.thenReturn(List.of(Map.of("payload_hash", requestHash, "status", "PROCESSED", "result_resource_id", 66L)));
assertThat(service.consume(event)).isEqualTo(66L);
verifyNoInteractions(firstleg);
}
@Test
void sameEventIdWithDifferentContentReturnsStableConflictCode() {
FinanceFeeSourceEvent event = inboundEvent("conflict-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("conflict-1")))
.thenReturn(List.of(Map.of("payload_hash", "different", "status", "PROCESSED", "result_resource_id", 66L)));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002");
}
@Test
void rejectsSnapshotBelongingToAnotherCustomer() {
FinanceFeeSourceEvent event = inboundEvent("tenant-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("tenant-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null)));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-007");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void rejectsApprovedButUnpublishedNonVoidedFee() {
FinanceFeeSourceEvent event = inboundEvent("draft-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("draft-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "DRAFT", new BigDecimal("100.00"))));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-015");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void rejectsZeroAmount() {
assertInvalidAmount("zero-1", BigDecimal.ZERO);
}
@Test
void rejectsNegativeAmount() {
assertInvalidAmount("negative-1", new BigDecimal("-0.01"));
}
private void givenNewEvent() {
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), anyString())).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), anyInt())).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq(31L), anyInt())).thenReturn(101L);
}
private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) {
return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload("21", null, "31", version));
}
private FinanceFeeSourceEvent containerEvent(String id, String type, int version, long tenant) {
return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload(null, "41", "31", version));
}
private FinanceFeeSourceEvent event(String id, String type, int version, long tenant, FinanceFeeSourceEvent.Payload payload) {
try {
return new FinanceFeeSourceEvent(id, type, 1, "firstleg-service", LocalDateTime.of(2026, 8, 11, 1, 2),
String.valueOf(tenant), payload.inboundId() == null ? "CONTAINER" : "INBOUND",
payload.inboundId() == null ? payload.containerId() : payload.inboundId(), version,
id, sha256(mapper.writeValueAsString(payload)), payload);
} catch (Exception error) {
throw new AssertionError(error);
}
}
private FirstlegFeeSourceClient.Snapshot snapshot(String sourceType, long customerId, int version, Long inboundId, Long containerId) {
return snapshot(sourceType, customerId, version, inboundId, containerId, "PUBLISHED", new BigDecimal("100.00"));
}
private FirstlegFeeSourceClient.Snapshot snapshot(String sourceType, long customerId, int version,
Long inboundId, Long containerId,
String publicationStatus, BigDecimal amount) {
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
inboundId == null ? null : "IB-21", 51L, containerId, null,
containerId == null ? null : "C-41", customerId, "客户", 61L, "CUSTOMS",
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
"APPROVED", publicationStatus, true, LocalDateTime.of(2026, 8, 11, 1, 0),
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
}
private void assertInvalidAmount(String eventId, BigDecimal amount) {
FinanceFeeSourceEvent event = inboundEvent(eventId, "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq(eventId))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "PUBLISHED", amount)));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-010");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
private static String sha256(String value) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceMigrationContractTest {
@Test
void moduleMigrationAddsImmutableSourceAndInboxIdempotencyFields() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/fee-source-event-migration.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("information_schema.columns", "source_type", "direction", "container_id",
"source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code");
assertThat(sql).contains("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废");
assertThat(sql).contains("finance_fee_source_assert_schema");
}
}
@@ -0,0 +1,78 @@
package com.baiyue.finance;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import com.baiyue.finance.controller.ClientFinanceController;
import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FinanceReadApiBehaviorTest {
@Mock FinanceService service;
@Mock FinanceQueryService queryService;
@Mock FinanceFileClient fileClient;
@Mock FirstlegCostSnapshotClient snapshotClient;
@Test
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
ClientReceipt row = new ClientReceipt(4L, "RCPT-4", new BigDecimal("300.00"), LocalDateTime.of(2026, 8, 11, 10, 0),
"BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00"))));
when(queryService.clientReceipts(23L)).thenReturn(List.of(row));
var response = new ClientFinanceController(service, queryService).receipts(user);
verify(queryService).clientReceipts(23L);
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data());
assertThat(json).doesNotContainIgnoringCase("voucher").doesNotContain("createdBy").doesNotContain("updatedBy").doesNotContain("tenantId");
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
new ClientFinanceController(service, queryService).bill(user, 88L);
verify(queryService).statement(23L, 88L, true);
}
@Test
void platformFeePoolPassesExplicitCustomerScopeAndReturnsTraceFields() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
var response = controller.feePool(31L, "RECEIVABLE");
verify(queryService).feePool(31L, "RECEIVABLE");
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
}
@Test
void fivePlatformPageMethodsUseTypedQueryFacade() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
controller.customerAccount(11L);
controller.statements(11L);
controller.receivableSettlements(11L);
controller.payableSettlements(11L);
verify(queryService).customerAccount(11L);
verify(queryService).statements(11L, false);
verify(queryService).receivableSettlements(11L, false);
verify(queryService).payableSettlements(11L);
}
}
@@ -3,6 +3,8 @@ package com.baiyue.finance;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -71,6 +73,19 @@ class FinanceServiceValidationTest {
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3"));
}
@Test
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
BizException error = assertThrows(BizException.class, () -> service.createReceipt(9L, 2L, request, "file-down"));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, error.getResultCode());
verify(jdbc, never()).update(startsWith("INSERT INTO fin_receipt"), (Object[]) any());
}
@Test
void rejectsVoucherFromAnotherCustomer() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -0,0 +1,60 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSqlCommentContractTest {
private static final Pattern TABLE = Pattern.compile("CREATE TABLE IF NOT EXISTS (fin_[a-z0-9_]+) ");
private static final Pattern COLUMN = Pattern.compile("^\\s{2}([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|VARCHAR|CHAR|DECIMAL|DATETIME|DATE|JSON|TINYINT)\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern CHINESE = Pattern.compile("[\\p{IsHan}]");
@Test
void everyFinanceBaselineColumnHasMeaningfulChineseComment() throws Exception {
List<String> lines = Files.readAllLines(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
int tableCount = 0;
int columnCount = 0;
String currentTable = null;
List<String> missing = new ArrayList<>();
for (String line : lines) {
var table = TABLE.matcher(line);
if (table.find()) {
currentTable = table.group(1);
tableCount++;
continue;
}
if (currentTable == null) continue;
if (line.startsWith(") ENGINE=")) {
currentTable = null;
continue;
}
var column = COLUMN.matcher(line);
if (!column.find()) continue;
columnCount++;
int comment = line.indexOf("COMMENT '");
if (comment < 0 || !CHINESE.matcher(line.substring(comment)).find()) {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(13);
assertThat(columnCount).isEqualTo(184);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@Test
void keyIsolationStatusSnapshotAndIdempotencyCommentsUseFrozenMeaning() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键'");
assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
}
}
@@ -0,0 +1,43 @@
package com.baiyue.finance;
import com.baiyue.finance.controller.InternalFinanceFeeSourceController;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class InternalFinanceFeeSourceControllerTest {
private final FinanceFeeSourceEventService service = mock(FinanceFeeSourceEventService.class);
private final InternalFinanceFeeSourceController controller = controller();
@Test
void successfulAcknowledgementAlwaysHasTrackingFields() {
FinanceFeeSourceEvent event = mock(FinanceFeeSourceEvent.class);
when(service.consume(event)).thenReturn(91L);
var response = controller.consume("internal-token", event);
assertThat(response.data()).containsEntry("feeSourceId", 91L);
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
}
@Test
void missingInternalTokenFailsClosedWithStableCode() {
assertThatThrownBy(() -> controller.consume(null, mock(FinanceFeeSourceEvent.class)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-401");
}
private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
ReflectionTestUtils.setField(value, "expectedToken", "internal-token");
return value;
}
}
+4
View File
@@ -17,6 +17,9 @@
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java` 及相邻 Service/Entity |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` |
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
@@ -33,6 +36,7 @@
- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 测量审核→计费确认→费用发布→入库完成不得跳过门禁;状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
## 三期装柜与 PDA 资源结算
@@ -4,6 +4,7 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
@@ -19,6 +20,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
"com.baiyue.firstleg.customs.mapper"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
@EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueFirstlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); }
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.FinanceFeeSourceEvent;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import com.fasterxml.jackson.annotation.JsonProperty;
@FeignClient(name = "finance-service", contextId = "firstlegFinanceFeeSourceEventClient",
url = "${baiyue.clients.finance.url:}", path = "/internal/finance/v1/fee-source-events")
public interface FinanceFeeSourceEventClient {
@PostMapping
ApiResponse<Acknowledgement> receive(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody FinanceFeeSourceEvent event);
record Acknowledgement(@JsonProperty("feeSourceId") Long feeSourceId) {
}
}
@@ -0,0 +1,50 @@
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.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import lombok.RequiredArgsConstructor;
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.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequiredArgsConstructor
@RequestMapping("/internal/firstleg/v1")
public class InternalFinanceFeeSourceController {
private final FinanceFeeSourceSnapshotService service;
@Value("${baiyue.security.internal-service-token}")
private String expectedToken;
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
public ApiResponse<FinanceFeeSourceSnapshot> inbound(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long inboundId, @PathVariable long feeId) {
authenticate(token);
return ApiResponse.success(service.inbound(inboundId, feeId));
}
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
public ApiResponse<FinanceFeeSourceSnapshot> container(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long containerId, @PathVariable long feeId) {
authenticate(token);
return ApiResponse.success(service.container(containerId, feeId));
}
private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
}
}
@@ -0,0 +1,93 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.RejectRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.VersionRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.service.ContainerFeeDetailService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/loading-plans/{loadingPlanId}/fee-details")
public class PlatformContainerFeeDetailController {
private final ContainerFeeDetailService service;
@GetMapping
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:view')")
public ApiResponse<List<ContainerFeeDetailEntity>> list(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId,
@PathVariable long loadingPlanId) {
return ApiResponse.success(service.list(FirstlegScopeGuard.platform(user, accountId),
loadingPlanId));
}
@PostMapping
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_CREATE", resourceType = "LOADING_PLAN", resourceId = "#loadingPlanId", captureBefore = false)
public ApiResponse<ContainerFeeDetailEntity> create(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId,
@PathVariable long loadingPlanId, @Valid @RequestBody SaveRequest request) {
return ApiResponse.success(service.create(FirstlegScopeGuard.platform(user, accountId),
loadingPlanId, user.userId(), request));
}
@PostMapping("/{feeId}/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_APPROVE", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long loadingPlanId,
@PathVariable long feeId) {
service.approve(FirstlegScopeGuard.platform(user, accountId), loadingPlanId, feeId,
user.userId(), user.username());
return ApiResponse.success(null);
}
@PostMapping("/{feeId}/reject")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_REJECT", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
public ApiResponse<Void> reject(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long loadingPlanId,
@PathVariable long feeId,
@Valid @RequestBody RejectRequest request) {
service.reject(FirstlegScopeGuard.platform(user, accountId), loadingPlanId, feeId,
user.userId(), user.username(), request.reason());
return ApiResponse.success(null);
}
@PostMapping("/{feeId}/adjust")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_ADJUST", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
public ApiResponse<ContainerFeeDetailEntity> adjust(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId,
@PathVariable long loadingPlanId, @PathVariable long feeId,
@Valid @RequestBody AdjustRequest request) {
return ApiResponse.success(service.adjust(FirstlegScopeGuard.platform(user, accountId),
loadingPlanId, feeId, request.expectedSourceVersion(), user.userId(), request.values()));
}
@PostMapping("/{feeId}/void")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_VOID", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
public ApiResponse<Void> voidApproved(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId,
@PathVariable long loadingPlanId, @PathVariable long feeId,
@Valid @RequestBody VersionRequest request) {
service.voidApproved(FirstlegScopeGuard.platform(user, accountId), loadingPlanId, feeId,
request.expectedSourceVersion(), user.userId());
return ApiResponse.success(null);
}
}
@@ -5,6 +5,7 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.RejectRequest;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.VersionRequest;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundFeeDetailService;
@@ -104,4 +105,32 @@ public class PlatformInboundFeeDetailController {
user.userId(), user.username(), request.reason());
return ApiResponse.success(null);
}
@PostMapping("/{detailId}/adjust")
@BusinessAudit(event = "INBOUND_FEE_DETAIL_ADJUST", resourceType = "INBOUND_FEE_DETAIL", resourceId = "#detailId", description = "调整已审核入库费用明细")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
public ApiResponse<InboundFeeDetailEntity> adjust(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@PathVariable long detailId,
@RequestParam int expectedSourceVersion,
@Valid @RequestBody SaveRequest request) {
return ApiResponse.success(service.adjust(FirstlegScopeGuard.platform(user, accountId),
inboundId, detailId, expectedSourceVersion, user.userId(), request));
}
@PostMapping("/{detailId}/void")
@BusinessAudit(event = "INBOUND_FEE_DETAIL_VOID", resourceType = "INBOUND_FEE_DETAIL", resourceId = "#detailId", description = "作废已审核入库费用明细")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<Void> voidApproved(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@PathVariable long detailId,
@Valid @RequestBody VersionRequest request) {
service.voidApproved(FirstlegScopeGuard.platform(user, accountId), inboundId, detailId,
request.expectedSourceVersion(), user.userId());
return ApiResponse.success(null);
}
}
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
public final class ContainerFeeDetailDtos {
private ContainerFeeDetailDtos() {
}
public record SaveRequest(
@NotNull @Min(1) Long feeItemId,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull Boolean settlementIncluded,
@Size(max = 500) String remark
) {
}
public record AdjustRequest(
@NotNull @Min(1) Integer expectedSourceVersion,
@NotNull @Min(1) Long feeItemId,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull Boolean settlementIncluded,
@Size(max = 500) String remark
) {
public SaveRequest values() {
return new SaveRequest(feeItemId, direction, businessDate, amount, currency,
settlementIncluded, remark);
}
}
public record VersionRequest(@NotNull @Min(1) Integer expectedSourceVersion) {
}
public record RejectRequest(@NotBlank @Size(max = 500) String reason) {
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.dto;
import java.time.LocalDateTime;
/** Firstleg 向 Finance 发布的短事件信封;完整金额必须回查来源快照接口。 */
public record FinanceFeeSourceEvent(
String eventId,
String eventType,
int eventVersion,
String producer,
LocalDateTime occurredAt,
String tenantId,
String aggregateType,
String aggregateId,
int sequence,
String idempotencyKey,
String payloadHash,
Payload payload
) {
public record Payload(String inboundId, String containerId, String feeId, int feeVersion) {
}
}
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Finance 回查的 Firstleg 费用来源只读快照。 */
public record FinanceFeeSourceSnapshot(
long tenantId,
String sourceType,
long feeId,
int feeVersion,
Long inboundId,
String inboundCode,
Long orderId,
Long containerId,
Long containerBookingId,
String containerNo,
long customerId,
String customerName,
Long feeItemId,
String feeItemCode,
String feeItemName,
String direction,
LocalDate businessDate,
String currency,
BigDecimal totalAmount,
String reviewStatus,
String publicationStatus,
boolean settlementIncluded,
LocalDateTime publishedAt,
BigDecimal chargeableQuantity,
String chargeUnit,
BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm,
String sourceHash,
int snapshotVersion
) {
}
@@ -21,8 +21,17 @@ public final class InboundFeeDetailDtos {
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotNull Boolean settlementIncluded,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal unitPrice, String currency, BigDecimal amount,
String remark) {
this(feeItemId, direction, businessDate, unitPrice, currency, amount, false, remark);
}
}
public record VersionRequest(@NotNull @jakarta.validation.constraints.Min(1) Integer expectedSourceVersion) {
}
public record RejectRequest(@NotBlank @Size(max = 500) String reason) {
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 柜次级人工应收/应付费用来源。 */
@Getter
@Setter
@TableName("fl_container_fee_detail")
public class ContainerFeeDetailEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long loadingPlanId;
private Long containerBookingId;
private String containerNo;
private Long feeItemId;
private String feeItemCodeSnapshot;
private String feeItemNameSnapshot;
private String direction;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
private Boolean settlementIncluded;
private String reviewStatus;
private String sourceStatus;
private Integer sourceVersion;
private String remark;
private Long reviewerId;
private String reviewerName;
private LocalDateTime reviewedAt;
private String reviewReason;
private Long createdBy;
private LocalDateTime createdAt;
private Long updatedBy;
private LocalDateTime updatedAt;
}
@@ -19,8 +19,17 @@ public class InboundOutboxEntity {
private String aggregateType;
private Long aggregateId;
private String eventType;
private String eventKey;
private Integer eventVersion;
private Integer aggregateSequence;
private String payloadHash;
private String payloadJson;
private String publishStatus;
private Integer retryCount;
private LocalDateTime nextRetryAt;
private LocalDateTime publishedAt;
private LocalDateTime lockedAt;
private String lockedBy;
private String lastError;
private LocalDateTime createdAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContainerFeeDetailMapper extends BaseMapper<ContainerFeeDetailEntity> {
}
@@ -0,0 +1,81 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.mapper.ContainerFeeDetailMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class ContainerFeeDetailRepository {
private final ContainerFeeDetailMapper mapper;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
return mapper.selectList(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.orderByDesc(ContainerFeeDetailEntity::getBusinessDate)
.orderByDesc(ContainerFeeDetailEntity::getId));
}
public ContainerFeeDetailEntity find(long tenantId, long loadingPlanId, long feeId) {
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId));
}
public ContainerFeeDetailEntity find(long loadingPlanId, long feeId) {
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId));
}
public void insert(ContainerFeeDetailEntity value) {
mapper.insert(value);
}
public int review(long tenantId, long loadingPlanId, long feeId, String status,
long reviewerId, String reviewerName, String reason) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId)
.eq(ContainerFeeDetailEntity::getReviewStatus, "PENDING")
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
.set(ContainerFeeDetailEntity::getReviewStatus, status)
.set(ContainerFeeDetailEntity::getReviewerId, reviewerId)
.set(ContainerFeeDetailEntity::getReviewerName, reviewerName)
.set(ContainerFeeDetailEntity::getReviewedAt, LocalDateTime.now())
.set(ContainerFeeDetailEntity::getReviewReason, reason)
.set(ContainerFeeDetailEntity::getUpdatedBy, reviewerId));
}
public int adjust(ContainerFeeDetailEntity value, int expectedVersion) {
return mapper.update(value, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, value.getTenantId())
.eq(ContainerFeeDetailEntity::getLoadingPlanId, value.getLoadingPlanId())
.eq(ContainerFeeDetailEntity::getId, value.getId())
.eq(ContainerFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
.eq(ContainerFeeDetailEntity::getSourceVersion, expectedVersion));
}
public int voidApproved(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId)
.eq(ContainerFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
.eq(ContainerFeeDetailEntity::getSourceVersion, expectedVersion)
.set(ContainerFeeDetailEntity::getSourceStatus, "VOIDED")
.set(ContainerFeeDetailEntity::getUpdatedBy, operatorId));
}
}
@@ -113,6 +113,18 @@ public class FeeItemRepository {
.eq(InboundFeeDetailEntity::getId, detailId));
}
public InboundFeeDetailEntity findDetail(long inboundId, long detailId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getId, detailId));
}
public InboundFeeDetailEntity findBySourceFee(String sourceType, long sourceFeeId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getSourceType, sourceType)
.eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId));
}
public InboundFeeDetailEntity findBySourceFee(long tenantId, String sourceType, long sourceFeeId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
@@ -171,6 +183,33 @@ public class FeeItemRepository {
.set(InboundFeeDetailEntity::getReviewerName, reviewerName)
.set(InboundFeeDetailEntity::getReviewedAt, LocalDateTime.now())
.set(InboundFeeDetailEntity::getReviewReason, reason)
.set("APPROVED".equals(targetStatus), InboundFeeDetailEntity::getPublicationStatus,
"PUBLISHED")
.set("APPROVED".equals(targetStatus), InboundFeeDetailEntity::getPublishedBy, reviewerId)
.set("APPROVED".equals(targetStatus), InboundFeeDetailEntity::getPublishedByName, reviewerName)
.set("APPROVED".equals(targetStatus), InboundFeeDetailEntity::getPublishedAt, LocalDateTime.now())
.set(InboundFeeDetailEntity::getUpdatedBy, reviewerId));
}
public int adjustApproved(InboundFeeDetailEntity detail, int expectedSourceVersion) {
return feeDetails.update(detail, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, detail.getTenantId())
.eq(InboundFeeDetailEntity::getInboundId, detail.getInboundId())
.eq(InboundFeeDetailEntity::getId, detail.getId())
.eq(InboundFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion));
}
public int voidApproved(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId) {
return feeDetails.update(null, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getId, detailId)
.eq(InboundFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion)
.ne(InboundFeeDetailEntity::getPublicationStatus, "VOIDED")
.set(InboundFeeDetailEntity::getPublicationStatus, "VOIDED")
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId));
}
}
@@ -0,0 +1,107 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class FinanceFeeSourceOutboxRepository {
private static final List<String> EVENT_TYPES = List.of(
"FIRSTLEG_FEE_PUBLISHED", "FIRSTLEG_FEE_ADJUSTED", "FIRSTLEG_FEE_VOIDED");
private final InboundOutboxMapper mapper;
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
.in(InboundOutboxEntity::getEventType, EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.lt(InboundOutboxEntity::getRetryCount, 12)
.and(value -> value.isNull(InboundOutboxEntity::getNextRetryAt)
.or().le(InboundOutboxEntity::getNextRetryAt, now))
.orderByAsc(InboundOutboxEntity::getCreatedAt)
.orderByAsc(InboundOutboxEntity::getId)
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
}
public boolean claim(long id, String workerId, LocalDateTime now) {
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.set(InboundOutboxEntity::getLockedAt, now)
.set(InboundOutboxEntity::getLockedBy, workerId)) == 1;
}
public InboundOutboxEntity findClaimed(long id, String workerId) {
return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.eq(InboundOutboxEntity::getLockedBy, workerId));
}
public boolean published(long id, String workerId, LocalDateTime now) {
return mapper.update(null, claimed(id, workerId)
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED")
.set(InboundOutboxEntity::getPublishedAt, now)
.set(InboundOutboxEntity::getNextRetryAt, null)
.set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLastError, null)) == 1;
}
public boolean retry(long id, String workerId, LocalDateTime nextRetryAt,
String error, boolean exhausted) {
return mapper.update(null, claimed(id, workerId)
.setSql("retry_count = retry_count + 1")
.set(InboundOutboxEntity::getPublishStatus, exhausted ? "FAILED" : "PENDING")
.set(InboundOutboxEntity::getNextRetryAt, exhausted ? null : nextRetryAt)
.set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLastError, truncate(error))) == 1;
}
public int recoverStale(LocalDateTime staleBefore, LocalDateTime retryAt, int maxRetries) {
int failed = mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.in(InboundOutboxEntity::getEventType, EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, staleBefore)
.ge(InboundOutboxEntity::getRetryCount, maxRetries - 1)
.setSql("retry_count = retry_count + 1")
.set(InboundOutboxEntity::getPublishStatus, "FAILED")
.set(InboundOutboxEntity::getNextRetryAt, null)
.set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLastError, "relay lease expired"));
int pending = mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.in(InboundOutboxEntity::getEventType, EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, staleBefore)
.lt(InboundOutboxEntity::getRetryCount, maxRetries - 1)
.setSql("retry_count = retry_count + 1")
.set(InboundOutboxEntity::getPublishStatus, "PENDING")
.set(InboundOutboxEntity::getNextRetryAt, retryAt)
.set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLastError, "relay lease expired"));
return failed + pending;
}
private static LambdaUpdateWrapper<InboundOutboxEntity> claimed(long id, String workerId) {
return new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.eq(InboundOutboxEntity::getLockedBy, workerId);
}
private static String truncate(String value) {
if (value == null || value.isBlank()) return "unknown relay error";
String sanitized = value.replaceAll("[\\r\\n\\t]+", " ").trim();
return sanitized.length() <= 500 ? sanitized : sanitized.substring(0, 500);
}
}
@@ -0,0 +1,162 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ContainerFeeDetailService {
private final ContainerFeeDetailRepository repository;
private final SmartLoadingRepository loading;
private final FeeItemRepository feeItems;
private final FinanceFeeSourceOutboxService financeOutbox;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
requireOwnedPlan(tenantId, loadingPlanId);
return repository.list(tenantId, loadingPlanId);
}
@Transactional
public ContainerFeeDetailEntity create(long tenantId, long loadingPlanId, long operatorId,
SaveRequest request) {
PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId);
FeeItemEntity item = requireItem(request.feeItemId(), request.direction());
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setTenantId(tenantId);
value.setLoadingPlanId(loadingPlanId);
value.setContainerBookingId(plan.plan().getContainerBookingId());
value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo());
value.setSourceVersion(1);
value.setSourceStatus("ACTIVE");
value.setReviewStatus("PENDING");
value.setCreatedBy(operatorId);
value.setCreatedAt(LocalDateTime.now());
apply(value, item, request, operatorId);
repository.insert(value);
return value;
}
@Transactional
public void approve(long tenantId, long loadingPlanId, long feeId,
long reviewerId, String reviewerName) {
required(tenantId, loadingPlanId, feeId);
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核柜次费用可以审核通过");
}
ContainerFeeDetailEntity approved = required(tenantId, loadingPlanId, feeId);
if (Boolean.TRUE.equals(approved.getSettlementIncluded())) {
String eventType = approved.getSourceVersion() > 1
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
approved.getSourceVersion(), LocalDateTime.now());
}
}
@Transactional
public void reject(long tenantId, long loadingPlanId, long feeId, long reviewerId,
String reviewerName, String reason) {
required(tenantId, loadingPlanId, feeId);
if (repository.review(tenantId, loadingPlanId, feeId, "REJECTED", reviewerId,
reviewerName, reason.trim()) != 1) {
throw conflict("只有待审核柜次费用可以驳回");
}
}
@Transactional
public ContainerFeeDetailEntity adjust(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId,
SaveRequest request) {
requireOwnedPlan(tenantId, loadingPlanId);
ContainerFeeDetailEntity value = required(tenantId, loadingPlanId, feeId);
FeeItemEntity item = requireItem(request.feeItemId(), request.direction());
apply(value, item, request, operatorId);
value.setSourceVersion(expectedVersion + 1);
value.setReviewStatus("PENDING");
value.setReviewerId(null);
value.setReviewerName(null);
value.setReviewedAt(null);
value.setReviewReason(null);
if (repository.adjust(value, expectedVersion) != 1) {
throw conflict("只有当前版本的已审核柜次费用可以调整");
}
return required(tenantId, loadingPlanId, feeId);
}
@Transactional
public void voidApproved(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId) {
ContainerFeeDetailEntity current = required(tenantId, loadingPlanId, feeId);
if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核柜次费用可以作废");
}
if (Boolean.TRUE.equals(current.getSettlementIncluded())) {
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
}
}
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
LoadingPlanEntity plan = loading.plan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
boolean owned = loading.items(loadingPlanId).stream()
.anyMatch(item -> Long.valueOf(tenantId).equals(item.getAccountId()));
if (!owned) throw new BizException(ResultCode.NOT_FOUND, "柜次不属于该客户");
return new PlanContext(plan, plan.getContainerBookingId() == null
? null : loading.booking(plan.getContainerBookingId()));
}
private FeeItemEntity requireItem(long feeItemId, String direction) {
FeeItemEntity item = feeItems.findItem(feeItemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "费用项目不存在");
if (!"ACTIVE".equals(item.getStatus())) throw conflict("费用项目已停用");
boolean applicable = "RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getPickupReceivable()) || Boolean.TRUE.equals(item.getWarehouseReceivable())
: Boolean.TRUE.equals(item.getPickupPayable()) || Boolean.TRUE.equals(item.getWarehousePayable());
if (!applicable) throw conflict("费用项目未启用对应应收/应付方向");
return item;
}
private ContainerFeeDetailEntity required(long tenantId, long loadingPlanId, long feeId) {
ContainerFeeDetailEntity value = repository.find(tenantId, loadingPlanId, feeId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用明细不存在");
return value;
}
private static void apply(ContainerFeeDetailEntity value, FeeItemEntity item,
SaveRequest request, long operatorId) {
value.setFeeItemId(item.getId());
value.setFeeItemCodeSnapshot(item.getFeeCode());
value.setFeeItemNameSnapshot(item.getFeeTypeName());
value.setDirection(request.direction());
value.setBusinessDate(request.businessDate());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded()));
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
value.setUpdatedAt(LocalDateTime.now());
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) {
}
}
@@ -11,7 +11,6 @@ import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
@@ -44,6 +43,8 @@ public class FbaPricingService {
private Clock clock;
@Resource
private InboundFeeDetailService feeDetails;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
@@ -126,16 +127,8 @@ public class FbaPricingService {
throw conflict("费用未确认或入库单状态已变化,不能发送");
}
feeDetails.publishFreight(tenantId, inboundId, current.getId(), operatorId, null, now);
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_FEE_SENT");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + current.getId() + "\"}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(now);
repository.insertOutbox(event);
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
tenantId, inboundId, current.getId(), current.getFeeVersion(), now);
complete(token, current.getId(), now);
return response(tenantId, current.getId(), nextProcessStatus);
}
@@ -208,17 +201,8 @@ public class FbaPricingService {
repository.insertItem(extra);
}
feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now);
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_FEE_ADJUSTED");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\""
+ adjusted.getId() + "\",\"feeVersion\":" + nextVersion + "}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(now);
repository.insertOutbox(event);
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED,
tenantId, inboundId, adjusted.getId(), nextVersion, now);
complete(token, adjusted.getId(), now);
return response(tenantId, adjusted.getId(), "INBOUNDED");
}
@@ -0,0 +1,182 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.FinanceFeeSourceEventClient;
import com.baiyue.firstleg.dto.FinanceFeeSourceEvent;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.FinanceFeeSourceOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Set;
import java.util.UUID;
/** 将 Firstleg 费用来源 Outbox 至少一次投递到 Finance Inbox。 */
@Component
@Slf4j
public class FinanceFeeSourceOutboxRelay {
private static final int MAX_RETRIES = 12;
private static final Set<String> TYPES = Set.of(
FinanceFeeSourceOutboxService.PUBLISHED,
FinanceFeeSourceOutboxService.ADJUSTED,
FinanceFeeSourceOutboxService.VOIDED);
private final FinanceFeeSourceOutboxRepository repository;
private final FinanceFeeSourceEventClient client;
private final ObjectMapper objectMapper;
private final Clock clock;
private final String workerId;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public FinanceFeeSourceOutboxRelay(FinanceFeeSourceOutboxRepository repository,
FinanceFeeSourceEventClient client,
ObjectMapper objectMapper) {
this(repository, client, objectMapper, Clock.systemDefaultZone(), UUID.randomUUID().toString());
}
FinanceFeeSourceOutboxRelay(FinanceFeeSourceOutboxRepository repository,
FinanceFeeSourceEventClient client, ObjectMapper objectMapper,
Clock clock, String workerId) {
this.repository = repository;
this.client = client;
this.objectMapper = objectMapper;
this.clock = clock;
this.workerId = workerId;
}
@Scheduled(fixedDelayString = "${baiyue.finance.outbox-relay.fixed-delay-ms:5000}")
public void relay() {
LocalDateTime now = LocalDateTime.now(clock);
repository.recoverStale(now.minusMinutes(5), now.plusSeconds(10), MAX_RETRIES);
for (InboundOutboxEntity candidate : repository.pending(50, now)) {
if (!repository.claim(candidate.getId(), workerId, now)) continue;
InboundOutboxEntity event = repository.findClaimed(candidate.getId(), workerId);
if (event == null) continue;
deliver(event);
}
}
private void deliver(InboundOutboxEntity row) {
try {
FinanceFeeSourceEvent event = parseAndValidate(row);
ApiResponse<FinanceFeeSourceEventClient.Acknowledgement> response =
client.receive(internalToken, event.idempotencyKey(), event);
validateAcknowledgement(event, response);
if (!repository.published(row.getId(), workerId, LocalDateTime.now(clock))) {
log.warn("Finance fee event acknowledgement lost lease: eventId={}", row.getEventKey());
}
} catch (Exception exception) {
int retryCount = row.getRetryCount() == null ? 0 : row.getRetryCount();
boolean permanent = permanent(exception);
boolean exhausted = permanent || retryCount + 1 >= MAX_RETRIES;
String error = safeError(exception);
repository.retry(row.getId(), workerId,
LocalDateTime.now(clock).plusSeconds(backoffSeconds(retryCount + 1)),
error, exhausted);
log.warn("Finance fee event relay failed: eventId={}, retry={}, exhausted={}, reason={}",
row.getEventKey(), retryCount + 1, exhausted, error);
}
}
private FinanceFeeSourceEvent parseAndValidate(InboundOutboxEntity row) throws Exception {
FinanceFeeSourceEvent event = objectMapper.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
if (event == null || event.eventVersion() != 1 || !"firstleg-service".equals(event.producer())
|| !TYPES.contains(event.eventType()) || !event.eventType().equals(row.getEventType())
|| event.eventId() == null || !event.eventId().equals(row.getEventKey())
|| !event.idempotencyKey().equals(row.getEventKey())
|| !String.valueOf(row.getTenantId()).equals(event.tenantId())
|| !String.valueOf(row.getAggregateId()).equals(event.aggregateId())
|| !event.aggregateType().equals(row.getAggregateType())
|| event.payload() == null || event.payload().feeVersion() != event.sequence()
|| !sha256(objectMapper.writeValueAsString(event.payload())).equals(event.payloadHash())
|| !event.payloadHash().equals(row.getPayloadHash())) {
throw new InvalidEventException("费用来源事件信封、归属或哈希不一致");
}
if ("INBOUND".equals(event.aggregateType())
&& !event.aggregateId().equals(event.payload().inboundId())) {
throw new InvalidEventException("入库费用事件聚合归属不一致");
}
if ("CONTAINER".equals(event.aggregateType())
&& !event.aggregateId().equals(event.payload().containerId())) {
throw new InvalidEventException("柜次费用事件聚合归属不一致");
}
return event;
}
private static void validateAcknowledgement(
FinanceFeeSourceEvent event,
ApiResponse<FinanceFeeSourceEventClient.Acknowledgement> response) {
if (response != null && !response.successful()) {
String code = response.code();
boolean permanent = code != null && !Set.of("FIN-EVT-003", "FIN-EVT-005", "FIN-EVT-006").contains(code);
throw new FinanceRejectedException(code == null ? "FIN-EVT-UNKNOWN" : code,
response.message(), permanent);
}
if (response == null || response.data() == null
|| response.data().feeSourceId() == null || response.data().feeSourceId() <= 0) {
throw new IllegalStateException("Finance未确认费用来源事件");
}
}
private static boolean permanent(Exception exception) {
if (exception instanceof InvalidEventException) return true;
if (exception instanceof FinanceRejectedException rejected) return rejected.permanent;
if (exception instanceof FeignException feign) {
int status = feign.status();
return status >= 400 && status < 500 && status != 408 && status != 429;
}
return false;
}
private static long backoffSeconds(int retry) {
return switch (Math.min(Math.max(retry, 1), 5)) {
case 1 -> 10;
case 2 -> 30;
case 3 -> 120;
case 4 -> 600;
default -> 1800;
};
}
private String safeError(Exception exception) {
String value = exception.getMessage();
if (value == null || value.isBlank()) value = exception.getClass().getSimpleName();
if (internalToken != null && !internalToken.isBlank()) value = value.replace(internalToken, "***");
return value;
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private static final class InvalidEventException extends RuntimeException {
private InvalidEventException(String message) {
super(message);
}
}
private static final class FinanceRejectedException extends RuntimeException {
private final boolean permanent;
private FinanceRejectedException(String code, String message, boolean permanent) {
super(code + ": " + (message == null ? "Finance拒绝费用来源事件" : message));
this.permanent = permanent;
}
}
}
@@ -0,0 +1,96 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceEvent;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
/** 在业务事务内记录 Finance 费用来源事件,eventKey 保证重复动作不会重复入箱。 */
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceOutboxService {
public static final String PUBLISHED = "FIRSTLEG_FEE_PUBLISHED";
public static final String ADJUSTED = "FIRSTLEG_FEE_ADJUSTED";
public static final String VOIDED = "FIRSTLEG_FEE_VOIDED";
private final InboundOutboxMapper outbox;
private final ObjectMapper objectMapper;
public void publishInbound(String eventType, long tenantId, long inboundId,
long feeId, int feeVersion, LocalDateTime occurredAt) {
FinanceFeeSourceEvent.Payload payload = new FinanceFeeSourceEvent.Payload(
String.valueOf(inboundId), null, String.valueOf(feeId), feeVersion);
publish(event(eventType, tenantId, "INBOUND", inboundId, feeId, feeVersion,
payload, occurredAt));
}
public void publishContainer(String eventType, long tenantId, long containerId,
long feeId, int feeVersion, LocalDateTime occurredAt) {
FinanceFeeSourceEvent.Payload payload = new FinanceFeeSourceEvent.Payload(
null, String.valueOf(containerId), String.valueOf(feeId), feeVersion);
publish(event(eventType, tenantId, "CONTAINER", containerId, feeId, feeVersion,
payload, occurredAt));
}
private void publish(FinanceFeeSourceEvent event) {
InboundOutboxEntity value = new InboundOutboxEntity();
value.setTenantId(Long.parseLong(event.tenantId()));
value.setAggregateType(event.aggregateType());
value.setAggregateId(Long.parseLong(event.aggregateId()));
value.setEventType(event.eventType());
value.setEventKey(event.eventId());
value.setEventVersion(event.eventVersion());
value.setAggregateSequence(event.sequence());
value.setPayloadHash(event.payloadHash());
value.setPayloadJson(json(event));
value.setPublishStatus("PENDING");
value.setRetryCount(0);
value.setCreatedAt(event.occurredAt());
try {
outbox.insert(value);
} catch (DuplicateKeyException ignored) {
// 同一来源版本和状态的事件已经进入 Outbox,调用方可安全重试。
}
}
private FinanceFeeSourceEvent event(String eventType, long tenantId, String aggregateType,
long aggregateId, long feeId, int feeVersion,
FinanceFeeSourceEvent.Payload payload, LocalDateTime occurredAt) {
String idempotencyKey = "FIRSTLEG_FEE:" + aggregateType + ":" + aggregateId
+ ":" + feeId + ":" + feeVersion + ":" + eventType;
String payloadJson = json(payload);
return new FinanceFeeSourceEvent(idempotencyKey, eventType, 1, "firstleg-service",
occurredAt == null ? LocalDateTime.now() : occurredAt, String.valueOf(tenantId),
aggregateType, String.valueOf(aggregateId), feeVersion, idempotencyKey,
sha256(payloadJson), payload);
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "费用来源事件序列化失败");
}
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
}
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceSnapshotService {
private final FeeItemRepository inboundFees;
private final ContainerFeeDetailRepository containerFees;
public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId) {
InboundFeeDetailEntity detail = inboundFees.findDetail(inboundId, feeId);
if (detail == null) detail = inboundFees.findBySourceFee("FBA_PRICING", feeId);
if (detail == null || !Long.valueOf(inboundId).equals(detail.getInboundId())) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源不存在");
}
InboundOrderEntity inbound = inboundFees.findInbound(detail.getTenantId(), inboundId);
FeeItemEntity item = inboundFees.findItem(detail.getFeeItemId());
if (inbound == null || item == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源关联快照不完整");
}
long sourceFeeId = "FBA_PRICING".equals(detail.getSourceType())
? detail.getSourceFeeId() : detail.getId();
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
String canonical = String.join("|", "INBOUND_FEE", String.valueOf(sourceFeeId),
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(inboundId),
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
nullSafe(detail.getDirection()), detail.getAmount().toPlainString(),
nullSafe(detail.getCurrency()), String.valueOf(detail.getSettlementIncluded()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
detail.getTenantId(), inbound.getCustomerName(), item.getId(), item.getFeeCode(),
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
detail.getPublicationStatus(), Boolean.TRUE.equals(detail.getSettlementIncluded()),
detail.getPublishedAt(), detail.getChargeableQuantity(), detail.getChargeUnit(),
detail.getActualGrossWeightKg(), detail.getActualVolumeCbm(), sha256(canonical), 1);
}
public FinanceFeeSourceSnapshot container(long containerId, long feeId) {
ContainerFeeDetailEntity detail = containerFees.find(containerId, feeId);
if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用来源不存在");
int version = detail.getSourceVersion();
String publicationStatus = "VOIDED".equals(detail.getSourceStatus()) ? "VOIDED"
: "APPROVED".equals(detail.getReviewStatus()) ? "PUBLISHED" : "DRAFT";
String canonical = String.join("|", "CONTAINER_FEE", String.valueOf(feeId),
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(containerId),
nullSafe(detail.getFeeItemCodeSnapshot()), nullSafe(detail.getDirection()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
String.valueOf(detail.getSettlementIncluded()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getFeeItemId(),
detail.getFeeItemCodeSnapshot(), detail.getFeeItemNameSnapshot(), detail.getDirection(),
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
publicationStatus, Boolean.TRUE.equals(detail.getSettlementIncluded()),
detail.getReviewedAt(), null, null, null, null, sha256(canonical), 1);
}
private static String nullSafe(Object value) {
return value == null ? "" : String.valueOf(value);
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
}
@@ -6,7 +6,6 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -29,6 +28,8 @@ public class InboundCompletionOrchestrator {
private InboundIdempotencyService idempotency;
@Resource
private InboundFeeDetailService feeDetails;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
/**
* 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。
@@ -89,11 +90,12 @@ public class InboundCompletionOrchestrator {
if (repository.markFeeSent(tenantId, inboundId, feeId, operatorId) != 1) {
throw new BizException(ResultCode.CONFLICT, "费用快照已被并发发送或状态不正确");
}
feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId, operatorName,
LocalDateTime.now());
var published = feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId,
operatorName, LocalDateTime.now());
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"INBOUNDING", "INBOUNDED", "SEND_FEE_COMPLETE", null, operatorId, operatorName));
repository.insertOutbox(completedEvent(tenantId, inboundId, feeId));
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED, tenantId,
inboundId, feeId, published.getSourceFeeVersion(), LocalDateTime.now());
idempotency.complete(context, inboundId);
}
@@ -139,19 +141,6 @@ public class InboundCompletionOrchestrator {
return order;
}
private static InboundOutboxEntity completedEvent(long tenantId, long inboundId, long feeId) {
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_INBOUND_FEE_SENT");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + feeId + "\"}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(LocalDateTime.now());
return event;
}
private static BizException concurrentConflict() {
return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试");
}
@@ -28,6 +28,8 @@ public class InboundFeeDetailService {
private FeeItemRepository repository;
@Resource
private FbaPricingRepository pricingRepository;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId) {
requiredInbound(tenantId, inboundId);
@@ -132,8 +134,8 @@ public class InboundFeeDetailService {
apply(value, inbound, item, request, operatorId);
value.setReviewStatus("PENDING");
value.setSourceType("MANUAL");
value.setSourceFeeVersion(1);
value.setPublicationStatus("DRAFT");
value.setSettlementIncluded(false);
repository.insertDetail(value);
return value;
}
@@ -168,6 +170,49 @@ public class InboundFeeDetailService {
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核费用明细可以审核通过");
}
InboundFeeDetailEntity approved = required(tenantId, inboundId, detailId);
publishFinanceSource(approved, "AVAILABLE", LocalDateTime.now());
}
@Transactional
public InboundFeeDetailEntity adjust(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId,
SaveRequest request) {
InboundFeeDetailEntity value = required(tenantId, inboundId, detailId);
if (!"APPROVED".equals(value.getReviewStatus()) || "VOIDED".equals(value.getPublicationStatus())) {
throw conflict("只有审核通过且未作废的费用明细可以调整");
}
if (value.getSourceFeeVersion() == null || value.getSourceFeeVersion() != expectedSourceVersion) {
throw conflict("费用来源版本已变化,请刷新后重试");
}
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
FeeItemEntity item = requiredActiveItem(request.feeItemId());
apply(value, inbound, item, request, operatorId);
value.setSourceType("MANUAL");
value.setSourceFeeVersion(expectedSourceVersion + 1);
value.setReviewStatus("PENDING");
value.setReviewerId(null);
value.setReviewerName(null);
value.setReviewedAt(null);
value.setReviewReason(null);
value.setPublicationStatus("DRAFT");
if (repository.adjustApproved(value, expectedSourceVersion) != 1) {
throw conflict("费用明细已被其他人调整,请刷新后重试");
}
return required(tenantId, inboundId, detailId);
}
@Transactional
public void voidApproved(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId) {
InboundFeeDetailEntity current = required(tenantId, inboundId, detailId);
if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核费用明细可以作废");
}
if (Boolean.TRUE.equals(current.getSettlementIncluded())) {
current.setPublicationStatus("VOIDED");
publishFinanceSource(current, "VOIDED", LocalDateTime.now());
}
}
@Transactional
@@ -282,6 +327,7 @@ public class InboundFeeDetailService {
value.setUnitPrice(request.unitPrice().setScale(4, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded()));
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
}
@@ -289,4 +335,16 @@ public class InboundFeeDetailService {
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
private void publishFinanceSource(InboundFeeDetailEntity detail, String status,
LocalDateTime occurredAt) {
if (!Boolean.TRUE.equals(detail.getSettlementIncluded())) return;
if ("FBA_PRICING".equals(detail.getSourceType())) return;
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED
: version > 1 ? FinanceFeeSourceOutboxService.ADJUSTED
: FinanceFeeSourceOutboxService.PUBLISHED;
financeOutbox.publishInbound(eventType, detail.getTenantId(), detail.getInboundId(),
detail.getId(), version, occurredAt);
}
}
@@ -2,14 +2,23 @@ USE baiyue_firstleg;
-- 可靠审计本地 Outbox;与头程业务表位于同一数据库。
CREATE TABLE IF NOT EXISTS sys_audit_outbox (
event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, resource_type VARCHAR(64) NOT NULL,
resource_id VARCHAR(128) NULL, service_name VARCHAR(64) NULL, payload_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', attempt_count INT NOT NULL DEFAULT 0,
compensation_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL, dead_at DATETIME(3) NULL,
sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型',
resource_type VARCHAR(64) NOT NULL COMMENT '被审计业务资源类型',
resource_id VARCHAR(128) NULL COMMENT '被审计业务资源ID;无法确定时为空',
service_name VARCHAR(64) NULL COMMENT '产生审计事件的服务名称',
payload_json JSON NOT NULL COMMENT '待发送的审计事件完整负载快照',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD已转死信',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '正常发送累计尝试次数',
compensation_count INT NOT NULL DEFAULT 0 COMMENT '人工或自动补偿累计次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '允许下次重试的最早时间',
lease_owner VARCHAR(128) NULL COMMENT '当前发送任务租约持有者标识',
lease_until DATETIME(3) NULL COMMENT '当前发送任务租约到期时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次发送失败原因',
dead_at DATETIME(3) NULL COMMENT '转入死信状态的时间',
sent_at DATETIME(3) NULL COMMENT '首次成功发送时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录最后更新时间',
PRIMARY KEY (event_id), KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_audit_outbox_retention (status, sent_at)
) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
-- 每个客户固定四条箱规尺寸配置;用于实测尺寸归档和计费体积加权,不包含价格。
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
@@ -0,0 +1,119 @@
-- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行)
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DELIMITER $$
CREATE PROCEDURE firstleg_finance_add_column(
IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = target_table AND column_name = target_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key',
"VARCHAR(160) NULL COMMENT '费用来源事件幂等键;历史事件允许为空' AFTER event_type");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version',
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '事件信封版本' AFTER event_key");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence',
"INT UNSIGNED NULL COMMENT '同一费用来源的单调版本序号' AFTER event_version");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash',
"CHAR(64) NULL COMMENT '短payload规范化JSON的SHA-256' AFTER aggregate_sequence");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'next_retry_at',
"DATETIME(3) NULL COMMENT '下一次允许投递时间' AFTER retry_count");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'published_at',
"DATETIME(3) NULL COMMENT 'Finance确认接收时间' AFTER next_retry_at");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'locked_at',
"DATETIME(3) NULL COMMENT 'Relay抢占租约时间' AFTER published_at");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'locked_by',
"VARCHAR(64) NULL COMMENT 'Relay抢占实例ID' AFTER locked_at");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'last_error',
"VARCHAR(500) NULL COMMENT '最近一次投递失败摘要,不记录令牌' AFTER locked_by");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_add_index;
DELIMITER $$
CREATE PROCEDURE firstleg_finance_add_index(
IN target_table VARCHAR(64), IN target_index VARCHAR(64), IN index_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = target_table AND index_name = target_index
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD ', index_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL firstleg_finance_add_index('fl_inbound_outbox', 'uk_inbound_outbox_event_key',
'UNIQUE KEY `uk_inbound_outbox_event_key` (`event_key`)');
CALL firstleg_finance_add_index('fl_inbound_outbox', 'idx_inbound_outbox_finance_relay',
'KEY `idx_inbound_outbox_finance_relay` (`publish_status`, `next_retry_at`, `created_at`, `id`)');
DROP PROCEDURE IF EXISTS firstleg_finance_add_index;
CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id',
container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照',
container_no VARCHAR(64) NULL COMMENT '柜号快照',
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目ID',
fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照',
fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
settlement_included TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与财务结算来源池',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回',
source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废',
source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本',
remark VARCHAR(500) NULL COMMENT '人工备注',
reviewer_id BIGINT UNSIGNED NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核驳回原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status),
KEY idx_container_fee_settlement (tenant_id, review_status, settlement_included, source_status),
CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance';
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
DELIMITER $$
CREATE PROCEDURE firstleg_finance_assert_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_outbox'
AND column_name IN ('event_key', 'event_version', 'aggregate_sequence', 'payload_hash',
'next_retry_at', 'published_at', 'locked_at', 'locked_by', 'last_error')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:Outbox事件信封字段不完整';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用表不存在';
END IF;
END$$
DELIMITER ;
CALL firstleg_finance_assert_schema();
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
+4 -4
View File
@@ -5,11 +5,11 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_fulfillment_task (
id BIGINT UNSIGNED NOT NULL COMMENT '履约任务ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务中的订单逻辑ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
order_snapshot_json JSON NOT NULL COMMENT '创建履约任务时所需的订单业务快照',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约任务当前状态',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约状态:PENDING_ASSIGNMENT待指派、ASSIGNED已指派、PROCESSING处理中、COMPLETED已完成、CANCELLED已取消',
assignee_id BIGINT UNSIGNED NULL COMMENT '当前负责人ID',
assignee_name VARCHAR(64) NULL COMMENT '当前负责人名称快照',
assigned_at DATETIME(3) NULL COMMENT '最近指派时间',
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS fl_fulfillment_task (
CREATE TABLE IF NOT EXISTS fl_assignment_history (
id BIGINT UNSIGNED NOT NULL COMMENT '指派历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属履约任务ID',
previous_assignee_id BIGINT UNSIGNED NULL COMMENT '原负责人ID',
assignee_id BIGINT UNSIGNED NOT NULL COMMENT '新负责人ID',
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS fl_consumed_event (
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
@@ -14,15 +14,15 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
warehouse_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应收是否参与',
allocation_fee TINYINT(1) NOT NULL DEFAULT 0 COMMENT '平摊费用是否参与',
description VARCHAR(500) NULL COMMENT '说明',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE或DISABLED',
created_by BIGINT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用',
created_by BIGINT NULL COMMENT '创建人ID;系统初始化时可为空',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT NULL COMMENT '最后修改人ID;系统初始化时可为空',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_fee_item_code (fee_code),
KEY idx_fee_item_query (tenant_id, trade_scope, category, status, deleted),
@@ -45,26 +45,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
destination_code VARCHAR(32) NULL COMMENT '目的仓编码快照',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
package_count INT NULL COMMENT '件数快照',
actual_gross_weight_kg DECIMAL(18,4) NULL COMMENT '仓库实测毛重',
actual_volume_cbm DECIMAL(18,6) NULL COMMENT '体积',
volume_weight_kg DECIMAL(18,4) NULL COMMENT '材积重',
chargeable_weight_kg DECIMAL(18,4) NULL COMMENT '计费重量',
chargeable_volume_cbm DECIMAL(18,6) NULL COMMENT '计费体积',
unit_price DECIMAL(18,4) NOT NULL COMMENT '单价',
currency CHAR(3) NOT NULL COMMENT '币种',
amount DECIMAL(18,2) NOT NULL COMMENT '金额',
actual_gross_weight_kg DECIMAL(18,4) NULL COMMENT '仓库实测毛重快照,单位千克',
actual_volume_cbm DECIMAL(18,6) NULL COMMENT '实际体积快照,单位立方米',
volume_weight_kg DECIMAL(18,4) NULL COMMENT '材积重快照,单位千克',
chargeable_weight_kg DECIMAL(18,4) NULL COMMENT '费用计算采用的计费重量,单位千克',
chargeable_volume_cbm DECIMAL(18,6) NULL COMMENT '费用计算采用的计费体积,单位立方米',
unit_price DECIMAL(18,4) NOT NULL COMMENT '费用单价;计价单位由对应费用项目规则确定',
currency CHAR(3) NOT NULL COMMENT '金额币种三字码,如CNY',
amount DECIMAL(18,2) NOT NULL COMMENT '该费用明细金额,币种由currency字段指定',
remark VARCHAR(500) NULL COMMENT '备注',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、APPROVED、REJECTED',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
reviewer_id BIGINT NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核驳回原因',
created_by BIGINT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
created_by BIGINT NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT NULL COMMENT '最后修改人或系统主体ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
KEY idx_inbound_fee_detail (tenant_id, inbound_id, business_date, deleted),
KEY idx_inbound_fee_review (tenant_id, review_status, business_date, deleted),
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
-- 存量环境可重复执行:首次打印前冻结整单箱号;Print 服务继续保持无状态。
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
@@ -68,7 +68,7 @@ DROP PROCEDURE IF EXISTS phase2_measure_add_column;
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致',
@@ -6,17 +6,17 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_inbound_order (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态',
inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜',
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态',
fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废',
fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认',
notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态:NOT_CREATED未创建、PENDING待通知',
expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
@@ -79,7 +79,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
pickup_method VARCHAR(24) NULL COMMENT '提货方式',
pickup_at DATETIME(3) NULL COMMENT '提货时间',
@@ -100,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码',
@@ -120,7 +120,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot (
CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
loading_status VARCHAR(24) NULL COMMENT '配载状态快照',
container_no VARCHAR(64) NULL COMMENT '集装箱号',
@@ -142,7 +142,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
@@ -167,7 +167,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL',
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
@@ -176,7 +176,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
measured_at DATETIME(3) NULL COMMENT '设备测量时间',
status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态',
status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态:DRAFT草稿、RECEIVED已接收、SUBMITTED已提交、APPROVED已通过、REJECTED已驳回',
raw_payload_json JSON NULL COMMENT '第三方原始报文',
original_document_no VARCHAR(64) NULL COMMENT '原单据号码快照',
original_gross_weight_kg DECIMAL(18,3) NULL COMMENT '原单据毛重kg',
@@ -198,7 +198,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空',
external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空',
@@ -238,7 +238,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
id BIGINT UNSIGNED NOT NULL COMMENT '墨家联调收件记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID',
box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码',
scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
@@ -249,7 +249,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
machine_no VARCHAR(64) NOT NULL COMMENT '设备编号',
measured_at DATETIME(3) NOT NULL COMMENT '设备测量时间或服务接收时间',
image_file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务中的墨家联调图片ID',
status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '收件状态',
status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '测量回执匹配状态:UNMATCHED未匹配;当前首版仅记录未匹配回执',
match_failure_code VARCHAR(16) NOT NULL COMMENT '正式业务匹配失败码',
match_failure_reason VARCHAR(500) NOT NULL COMMENT '脱敏后的匹配失败原因',
request_id VARCHAR(128) NULL COMMENT '请求链路ID',
@@ -263,7 +263,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID',
ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码',
@@ -274,7 +274,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量',
adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg',
recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态',
recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认',
adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因',
adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID',
adjusted_at DATETIME(3) NULL COMMENT '修正时间',
@@ -294,7 +294,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致',
@@ -317,7 +317,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
CREATE TABLE IF NOT EXISTS fl_measure_review_record (
id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID',
review_result VARCHAR(24) NOT NULL COMMENT '审核结果',
@@ -337,8 +337,8 @@ CREATE TABLE IF NOT EXISTS fl_measure_review_record (
CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
rule_code VARCHAR(50) NOT NULL COMMENT '客户内唯一计费规则编码',
rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
@@ -351,7 +351,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
currency CHAR(3) NOT NULL COMMENT '默认币种',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
remark VARCHAR(500) NULL COMMENT '规则备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -394,7 +394,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
source_destination_text VARCHAR(1000) NULL COMMENT '原始组合仓库或区域文本',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED',
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED;分别表示待确认、已生效、已被替代;FAILED表示解析失败',
remark VARCHAR(500) NULL COMMENT '价格备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -417,9 +417,9 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet',
aliases_json JSON NOT NULL COMMENT '上传名称别名数组',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_price_channel_code (channel_code),
KEY idx_price_channel_status (status, price_model)
@@ -429,9 +429,9 @@ CREATE TABLE IF NOT EXISTS fl_price_origin_alias (
id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID',
warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID',
alias_name VARCHAR(128) NOT NULL COMMENT 'Excel起运仓名称别名',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_price_origin_alias (warehouse_code, alias_name),
KEY idx_price_origin_alias_name (alias_name, status)
@@ -444,9 +444,9 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping (
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '固定平台公共仓库ID',
warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码快照',
warehouse_type VARCHAR(16) NOT NULL COMMENT 'DOMESTIC或OVERSEAS',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_price_warehouse_mapping_source (source_type, source_value),
KEY idx_price_warehouse_mapping_target (warehouse_id, status)
@@ -459,20 +459,20 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '待确认上传记录;ACTIVE/SUPERSEDED/FAILED',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败',
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
channel_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析渠道数',
rate_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析价格数',
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_by BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NOT NULL COMMENT '上传价格表的操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传记录创建时间',
published_by BIGINT UNSIGNED NULL COMMENT '确认并发布价格版本的操作人ID',
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
updated_by BIGINT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_price_book_version (tenant_id, version_no),
@@ -496,9 +496,9 @@ CREATE TABLE IF NOT EXISTS fl_price_book_channel (
surcharge_description VARCHAR(2000) NULL COMMENT '附加费说明,仅展示',
remark VARCHAR(2000) NULL COMMENT '渠道备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '渠道快照创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '渠道快照最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
@@ -552,9 +552,9 @@ ON DUPLICATE KEY UPDATE
CREATE TABLE IF NOT EXISTS fl_inbound_fee (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号',
fee_no VARCHAR(40) NOT NULL COMMENT '客户内唯一费用单号',
fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本',
charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空',
charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照',
@@ -594,8 +594,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额',
additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额',
total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、SUPERSEDED已被新版本替代',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成',
calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID',
calculated_at DATETIME(3) NOT NULL COMMENT '试算时间',
@@ -616,7 +616,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID',
item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号',
fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码',
@@ -636,7 +636,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
@@ -650,7 +650,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
@@ -663,7 +663,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
CREATE TABLE IF NOT EXISTS fl_inbound_file (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID',
file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT',
@@ -681,7 +681,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_file (
CREATE TABLE IF NOT EXISTS fl_inbound_hold_record (
id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE',
reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因',
@@ -696,7 +696,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_hold_record (
CREATE TABLE IF NOT EXISTS fl_inbound_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
@@ -709,7 +709,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_internal_note (
CREATE TABLE IF NOT EXISTS fl_inbound_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
@@ -725,12 +725,12 @@ CREATE TABLE IF NOT EXISTS fl_inbound_status_history (
CREATE TABLE IF NOT EXISTS fl_inbound_outbox (
id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属客户ID和数据隔离键',
aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型',
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
payload_json JSON NOT NULL COMMENT '事件负载快照',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '事件发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
published_at DATETIME(3) NULL COMMENT '发布时间',
@@ -741,13 +741,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_outbox (
CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
operation VARCHAR(40) NOT NULL COMMENT '操作类型',
operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID',
inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0',
idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(16) NOT NULL COMMENT '处理状态',
status VARCHAR(16) NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED已成功、FAILED已失败',
result_json JSON NULL COMMENT '成功结果摘要',
result_resource_id BIGINT UNSIGNED NULL COMMENT '成功结果资源ID',
completed_at DATETIME(3) NULL COMMENT '完成时间',
@@ -760,7 +760,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
@@ -422,7 +422,7 @@ CALL phase2_publish_add_column('fl_inbound_fee_detail', 'chargeable_quantity',
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'charge_unit',
"VARCHAR(16) NULL COMMENT 'KG或CBM' AFTER chargeable_quantity");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'publication_status',
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT、PUBLISHED、SUPERSEDED' AFTER review_reason");
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代' AFTER review_reason");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_by',
"BIGINT NULL COMMENT '发布人ID' AFTER publication_status");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_by_name',
@@ -2,38 +2,38 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
id BIGINT UNSIGNED NOT NULL,
query_no VARCHAR(40) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED,
carrier_code VARCHAR(16) NOT NULL COMMENT 'MATSON/SML/ZIM/COSCO/EMC/HEDE',
tracking_type VARCHAR(32) NOT NULL COMMENT 'BOOKING_NO/BILL_OF_LADING/CONTAINER_NO',
tracking_no VARCHAR(128) NOT NULL,
official_url VARCHAR(500) NOT NULL,
access_mode VARCHAR(32) NOT NULL COMMENT 'PUBLIC_FORM/HCAPTCHA/DYNAMIC_IFRAME',
status VARCHAR(24) NOT NULL COMMENT 'READY/MANUAL_REQUIRED/SUCCESS',
window_started_at DATETIME(3) NOT NULL,
attempt_count INT UNSIGNED NOT NULL DEFAULT 0,
next_allowed_at DATETIME(3) NOT NULL,
last_attempt_at DATETIME(3) NULL,
last_success_at DATETIME(3) NULL,
source_updated_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL,
result_port_schedule_id BIGINT UNSIGNED NULL,
last_attempt_status VARCHAR(24) NULL,
manual_reason VARCHAR(1000) NULL,
last_error_code VARCHAR(64) NULL,
last_error_message VARCHAR(1000) NULL,
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询任务ID',
query_no VARCHAR(40) NOT NULL COMMENT '船司官网查询任务编号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空',
container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0',
carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德',
tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面',
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果',
window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间',
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次',
next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间',
last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间',
last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间',
source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间',
expires_at DATETIME(3) NULL COMMENT '当前最近有效结果过期时间;到期后仍保留但标记过期',
result_port_schedule_id BIGINT UNSIGNED NULL COMMENT '最近有效结果对应的船期记录ID',
last_attempt_status VARCHAR(24) NULL COMMENT '最近查询尝试状态快照',
manual_reason VARCHAR(1000) NULL COMMENT '需要人工查询或录入的原因',
last_error_code VARCHAR(64) NULL COMMENT '最近查询失败错误码',
last_error_message VARCHAR(1000) NULL COMMENT '最近查询失败错误说明',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_query_no (query_no),
UNIQUE KEY uk_carrier_schedule_query_scope
@@ -57,21 +57,21 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NOT NULL,
window_attempt_no INT UNSIGNED NOT NULL,
carrier_code VARCHAR(16) NOT NULL,
access_mode VARCHAR(32) NOT NULL,
official_url VARCHAR(500) NOT NULL,
attempt_status VARCHAR(24) NOT NULL,
coverage_gap TINYINT(1) NOT NULL DEFAULT 0,
coverage_gap_code VARCHAR(64) NULL,
manual_reason VARCHAR(1000) NULL,
error_code VARCHAR(64) NULL,
error_message VARCHAR(1000) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID',
window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3',
carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照',
access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照',
official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照',
attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败',
coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是',
coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码',
manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因',
error_code VARCHAR(64) NULL COMMENT '查询失败错误码',
error_message VARCHAR(1000) NULL COMMENT '查询失败错误说明',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行本次尝试的操作人ID',
operator_name VARCHAR(64) NULL COMMENT '执行操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '尝试记录创建时间',
PRIMARY KEY (id),
KEY idx_carrier_schedule_attempt_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
@@ -81,17 +81,17 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(24) NOT NULL,
from_version INT UNSIGNED NULL,
to_version INT UNSIGNED NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID',
action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果',
from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空',
to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本',
reason VARCHAR(1000) NULL COMMENT '操作原因或说明',
before_snapshot JSON NULL COMMENT '变更前任务数据快照;创建时为空',
after_snapshot JSON NOT NULL COMMENT '变更后任务数据快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间',
PRIMARY KEY (id),
KEY idx_carrier_schedule_history_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
@@ -99,16 +99,16 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(24) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID',
query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写操作的平台操作人ID',
operation VARCHAR(24) NOT NULL COMMENT '幂等写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功',
result_json JSON NULL COMMENT '成功结果JSON快照;供幂等重放返回',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写操作成功完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key),
KEY idx_carrier_schedule_operation_query (query_id,created_at,id),
@@ -2,17 +2,17 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
id BIGINT UNSIGNED NOT NULL,
checklist_code VARCHAR(40) NOT NULL,
version_no VARCHAR(32) NOT NULL,
country_code VARCHAR(32) NOT NULL,
channel_code VARCHAR(40) NOT NULL,
cargo_type VARCHAR(40) NOT NULL,
items_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '报关资料清单版本逻辑ID',
checklist_code VARCHAR(40) NOT NULL COMMENT '报关资料清单编码;同一清单的多个版本共用',
version_no VARCHAR(32) NOT NULL COMMENT '清单业务版本号',
country_code VARCHAR(32) NOT NULL COMMENT '清单适用的目的国家或地区编码',
channel_code VARCHAR(40) NOT NULL COMMENT '清单适用的运输渠道编码',
cargo_type VARCHAR(40) NOT NULL COMMENT '清单适用的货物类型编码',
items_json JSON NOT NULL COMMENT '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '清单版本状态:ACTIVE启用、RETIRED停用',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建清单版本的平台操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建清单版本时的平台操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清单版本创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_checklist_version (checklist_code, version_no),
KEY idx_customs_checklist_match (country_code, channel_code, cargo_type, status),
@@ -20,24 +20,24 @@ CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
) ENGINE=InnoDB COMMENT='三期报关资料清单不可变版本';
CREATE TABLE IF NOT EXISTS fl_customs_case (
id BIGINT UNSIGNED NOT NULL,
customs_no VARCHAR(40) NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
checklist_version_id BIGINT UNSIGNED NOT NULL,
checklist_code VARCHAR(40) NOT NULL,
checklist_version_no VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING',
remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '报关协同单逻辑ID',
customs_no VARCHAR(40) NOT NULL COMMENT '全局唯一报关协同单号',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order服务订单逻辑ID;跨库不建立外键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单逻辑ID',
container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联柜次逻辑ID;0表示创建时尚未关联柜次',
checklist_version_id BIGINT UNSIGNED NOT NULL COMMENT '本单冻结使用的报关资料清单版本ID',
checklist_code VARCHAR(40) NOT NULL COMMENT '创建协同单时的清单编码快照;源清单变化不回写',
checklist_version_no VARCHAR(32) NOT NULL COMMENT '创建协同单时的清单版本号快照;源清单变化不回写',
status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING' COMMENT '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消',
remark VARCHAR(500) NULL COMMENT '报关协同单内部业务备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建协同单的平台操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建协同单时的平台操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '协同单创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改协同单的平台操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改时的平台操作人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '协同单最后修改时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号;每次状态变更递增',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_no (customs_no),
UNIQUE KEY uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id),
@@ -48,23 +48,23 @@ CREATE TABLE IF NOT EXISTS fl_customs_case (
) ENGINE=InnoDB COMMENT='三期订单报关协同聚合';
CREATE TABLE IF NOT EXISTS fl_customs_document (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
checklist_item_code VARCHAR(40) NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,
document_version INT UNSIGNED NOT NULL,
replaces_document_id BIGINT UNSIGNED NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED',
visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER',
uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_type VARCHAR(16) NOT NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
reviewed_by BIGINT UNSIGNED NULL,
reviewed_by_name VARCHAR(64) NULL,
reviewed_at DATETIME(3) NULL,
review_reason VARCHAR(500) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '报关资料文件版本逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
checklist_item_code VARCHAR(40) NOT NULL COMMENT '对应冻结清单中的资料项目编码',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID;跨库不建立外键',
document_version INT UNSIGNED NOT NULL COMMENT '同一协同单和清单项目下的文件版本序号',
replaces_document_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一资料版本ID;首个版本为空',
file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '文件内容SHA-256摘要;用于版本审计和幂等校验',
status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED' COMMENT '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结',
visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER' COMMENT '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用资料的操作人ID',
uploaded_by_type VARCHAR(16) NOT NULL COMMENT '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料上传或引用时间',
reviewed_by BIGINT UNSIGNED NULL COMMENT '审核资料的平台操作人ID;未审核时为空',
reviewed_by_name VARCHAR(64) NULL COMMENT '审核时的平台操作人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '资料审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核退回原因或审核备注',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_document_version (customs_case_id, checklist_item_code, document_version),
UNIQUE KEY uk_customs_document_file (customs_case_id, file_id),
@@ -76,19 +76,19 @@ CREATE TABLE IF NOT EXISTS fl_customs_document (
) ENGINE=InnoDB COMMENT='报关客户文件版本引用';
CREATE TABLE IF NOT EXISTS fl_customs_supplement_request (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
source_document_id BIGINT UNSIGNED NOT NULL,
resolved_document_id BIGINT UNSIGNED NULL,
checklist_item_code VARCHAR(40) NOT NULL,
reason VARCHAR(500) NOT NULL,
due_at DATETIME(3) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
requested_by BIGINT UNSIGNED NOT NULL,
requested_by_name VARCHAR(64) NULL,
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
resolved_by BIGINT UNSIGNED NULL,
resolved_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '报关补件请求逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '触发本次补件的被退回资料版本ID',
resolved_document_id BIGINT UNSIGNED NULL COMMENT '解决本次补件的新资料版本ID;未解决时为空',
checklist_item_code VARCHAR(40) NOT NULL COMMENT '需要补充的报关资料项目编码',
reason VARCHAR(500) NOT NULL COMMENT '平台审核人员填写的补件原因',
due_at DATETIME(3) NULL COMMENT '期望完成补件时间;未设置时为空',
status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消',
requested_by BIGINT UNSIGNED NOT NULL COMMENT '发起补件的平台操作人ID',
requested_by_name VARCHAR(64) NULL COMMENT '发起补件时的平台操作人名称快照',
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '补件请求发起时间',
resolved_by BIGINT UNSIGNED NULL COMMENT '提交解决资料的操作人ID;未解决时为空',
resolved_at DATETIME(3) NULL COMMENT '补件请求解决时间',
PRIMARY KEY (id),
KEY idx_customs_supplement_case (customs_case_id, status, due_at),
CONSTRAINT fk_customs_supplement_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
@@ -96,18 +96,18 @@ CREATE TABLE IF NOT EXISTS fl_customs_supplement_request (
) ENGINE=InnoDB COMMENT='报关补件请求';
CREATE TABLE IF NOT EXISTS fl_customs_submission_batch (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
batch_no VARCHAR(40) NOT NULL,
batch_version INT UNSIGNED NOT NULL,
customs_agent_name VARCHAR(128) NOT NULL,
external_reference VARCHAR(128) NULL,
file_snapshot_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED',
note VARCHAR(500) NULL,
submitted_by BIGINT UNSIGNED NOT NULL,
submitted_by_name VARCHAR(64) NULL,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '报关代理提交批次逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
batch_no VARCHAR(40) NOT NULL COMMENT '全局唯一代理提交批次号',
batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号',
customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照',
external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号',
file_snapshot_json JSON NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash',
status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成',
note VARCHAR(500) NULL COMMENT '提交报关代理时的平台备注',
submitted_by BIGINT UNSIGNED NOT NULL COMMENT '提交报关代理的平台操作人ID',
submitted_by_name VARCHAR(64) NULL COMMENT '提交时的平台操作人名称快照',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_batch_no (batch_no),
UNIQUE KEY uk_customs_batch_version (customs_case_id, batch_version),
@@ -116,20 +116,20 @@ CREATE TABLE IF NOT EXISTS fl_customs_submission_batch (
) ENGINE=InnoDB COMMENT='报关代理提交批次及冻结文件版本';
CREATE TABLE IF NOT EXISTS fl_customs_receipt (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
submission_batch_id BIGINT UNSIGNED NOT NULL,
receipt_type VARCHAR(16) NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,
receipt_version INT UNSIGNED NOT NULL,
replaces_receipt_id BIGINT UNSIGNED NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM',
note VARCHAR(500) NULL,
uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_name VARCHAR(64) NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '报关回执文件版本逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
submission_batch_id BIGINT UNSIGNED NOT NULL COMMENT '产生本回执的报关代理提交批次ID',
receipt_type VARCHAR(16) NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键',
receipt_version INT UNSIGNED NOT NULL COMMENT '同一提交批次和回执类型下的版本序号',
replaces_receipt_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一回执版本ID;首个版本为空',
file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的回执文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '回执文件内容SHA-256摘要;用于版本审计和幂等校验',
visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM' COMMENT '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载',
note VARCHAR(500) NULL COMMENT '回执业务备注',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用回执的平台操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传回执时的平台操作人名称快照',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回执上传或引用时间',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_receipt_version (submission_batch_id, receipt_type, receipt_version),
UNIQUE KEY uk_customs_receipt_file (submission_batch_id, file_id),
@@ -141,18 +141,18 @@ CREATE TABLE IF NOT EXISTS fl_customs_receipt (
) ENGINE=InnoDB COMMENT='报关代理回执版本与客户可见范围';
CREATE TABLE IF NOT EXISTS fl_customs_history (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(40) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL,
note VARCHAR(500) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
operator_type VARCHAR(16) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '报关协同历史逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
action VARCHAR(40) NOT NULL COMMENT '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT',
from_status VARCHAR(32) NULL COMMENT '动作发生前的协同状态;创建动作时为空',
to_status VARCHAR(32) NULL COMMENT '动作完成后的协同状态',
resource_type VARCHAR(32) NOT NULL COMMENT '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执',
resource_id BIGINT UNSIGNED NOT NULL COMMENT '动作关联资源逻辑ID',
note VARCHAR(500) NULL COMMENT '动作原因、资料项目编码或业务备注快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行业务动作的操作人ID',
operator_name VARCHAR(64) NULL COMMENT '执行动作时的操作人名称快照',
operator_type VARCHAR(16) NOT NULL COMMENT '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间',
PRIMARY KEY (id),
KEY idx_customs_history_case (customs_case_id, created_at, id),
CONSTRAINT fk_customs_history_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
@@ -160,16 +160,16 @@ CREATE TABLE IF NOT EXISTS fl_customs_history (
) ENGINE=InnoDB COMMENT='报关协同稳定历史';
CREATE TABLE IF NOT EXISTS fl_customs_operation (
id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_type VARCHAR(24) NULL,
result_id BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '报关写命令幂等记录逻辑ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写命令的操作人ID;与动作和幂等键共同限定重放范围',
action VARCHAR(64) NOT NULL COMMENT '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端提供的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS处理成功',
result_type VARCHAR(24) NULL COMMENT '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT',
result_id BIGINT UNSIGNED NULL COMMENT '成功结果资源逻辑ID;处理中为空',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写命令成功完成时间;处理中为空',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_operation (operator_id, action, idempotency_key),
CONSTRAINT chk_customs_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
@@ -169,19 +169,19 @@ DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键',
expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本',
package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数',
status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED',
status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT '拆分批次状态:PREPARED已准备、APPLIED已生效、CANCELLED已取消',
remark VARCHAR(500) NULL COMMENT '拆分原因或备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分批次创建时间',
applied_at DATETIME(3) NULL COMMENT '批次生效时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id),
UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key),
@@ -193,7 +193,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
@@ -202,7 +202,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
source_measurement_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量批次ID',
source_measurement_item_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量箱规明细ID',
package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分数量台账创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
UNIQUE KEY uk_inbound_split_batch_source_child (tenant_id, split_batch_id, source_measurement_item_id, child_inbound_id),
@@ -217,16 +217,16 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
CREATE TABLE IF NOT EXISTS fl_inbound_split_history (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号',
action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL',
action_type VARCHAR(24) NOT NULL COMMENT '拆分动作:CREATE创建批次、APPLY生效、CANCEL取消',
before_snapshot JSON NULL COMMENT '操作前快照',
after_snapshot JSON NOT NULL COMMENT '操作后快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_reason VARCHAR(500) NULL COMMENT '操作原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拆分历史创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id),
KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id),
@@ -3,26 +3,26 @@ USE baiyue_firstleg;
/* 装柜证据、PDA 拣货复核和派送完成。五类证据按有效记录计数,同类型允许多图追加;作废记录保留审计。 */
CREATE TABLE IF NOT EXISTS fl_loading_evidence (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '装柜证据记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID',
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1空仓/PHOTO_STEP_2半仓/PHOTO_STEP_3满仓/PHOTO_STEP_4锁仓/SEAL带锁码照',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File 服务逻辑文件ID',
file_name VARCHAR(255) NULL,
seal_no VARCHAR(64) NULL,
remark VARCHAR(1000) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/VOID,作废不硬删除',
void_reason VARCHAR(500) NULL,
voided_by BIGINT UNSIGNED NULL,
voided_by_name VARCHAR(64) NULL,
voided_at DATETIME(3) NULL,
version INT NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
file_name VARCHAR(255) NULL COMMENT '上传时文件名称快照',
seal_no VARCHAR(64) NULL COMMENT '锁码文本;带锁码照必填,客户不可见',
remark VARCHAR(1000) NULL COMMENT '证据补充说明',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID已作废;错误照片保留记录不硬删除',
void_reason VARCHAR(500) NULL COMMENT '照片作废原因',
voided_by BIGINT UNSIGNED NULL COMMENT '执行作废的平台操作人ID',
voided_by_name VARCHAR(64) NULL COMMENT '执行作废的平台操作人姓名快照',
voided_at DATETIME(3) NULL COMMENT '照片作废时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
created_by BIGINT UNSIGNED NOT NULL COMMENT '上传证据的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '上传证据的操作人姓名快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '证据记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '证据记录最后更新时间',
PRIMARY KEY (id),
KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status),
KEY idx_loading_evidence_seal (tenant_id, seal_no, status),
@@ -46,11 +46,11 @@ BEGIN
END IF;
END$$
DELIMITER ;
CALL phase3_loading_add_column('status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''ACTIVE/VOID,作废不硬删除''');
CALL phase3_loading_add_column('void_reason', 'VARCHAR(500) NULL');
CALL phase3_loading_add_column('voided_by', 'BIGINT UNSIGNED NULL');
CALL phase3_loading_add_column('voided_by_name', 'VARCHAR(64) NULL');
CALL phase3_loading_add_column('voided_at', 'DATETIME(3) NULL');
CALL phase3_loading_add_column('status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''证据状态:ACTIVE有效、VOID已作废;错误照片保留记录不硬删除''');
CALL phase3_loading_add_column('void_reason', 'VARCHAR(500) NULL COMMENT ''照片作废原因''');
CALL phase3_loading_add_column('voided_by', 'BIGINT UNSIGNED NULL COMMENT ''执行作废的平台操作人ID''');
CALL phase3_loading_add_column('voided_by_name', 'VARCHAR(64) NULL COMMENT ''执行作废的平台操作人姓名快照''');
CALL phase3_loading_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''照片作废时间''');
DROP PROCEDURE IF EXISTS phase3_loading_add_column;
SET @phase3_loading_has_legacy_type_index = (
@@ -94,17 +94,17 @@ EXECUTE phase3_loading_seal_stmt;
DEALLOCATE PREPARE phase3_loading_seal_stmt;
CREATE TABLE IF NOT EXISTS fl_loading_operation (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(48) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '装柜写操作记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '操作目标入库单逻辑ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行写操作的平台用户ID',
operation VARCHAR(48) NOT NULL COMMENT '装柜或证据写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功、FAILED失败',
result_json JSON NULL COMMENT '成功结果或失败信息JSON快照,用于幂等重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作声明创建时间',
completed_at DATETIME(3) NULL COMMENT '操作完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key),
KEY idx_loading_operation_time (tenant_id, inbound_id, created_at),
@@ -112,18 +112,18 @@ CREATE TABLE IF NOT EXISTS fl_loading_operation (
) ENGINE=InnoDB COMMENT='装柜与派送写操作幂等记录';
CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/IN_PROGRESS/COMPLETED',
version INT NOT NULL DEFAULT 0,
expected_count INT UNSIGNED NULL,
picked_count INT UNSIGNED NULL,
remark VARCHAR(1000) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '入库单级PDA拣货任务ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:PENDING待拣货、IN_PROGRESS拣货中、COMPLETED已完成',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
expected_count INT UNSIGNED NULL COMMENT '计划应拣箱数',
picked_count INT UNSIGNED NULL COMMENT '实际已拣箱数',
remark VARCHAR(1000) NULL COMMENT '拣货结果或异常补充说明',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id),
KEY idx_loading_pick_status (tenant_id, status, updated_at),
@@ -10,9 +10,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link (
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次级装柜计划ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '建立关联幂等键',
request_hash CHAR(64) NOT NULL COMMENT '建立关联请求SHA-256',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT UNSIGNED NOT NULL COMMENT '建立预装与装柜计划关联的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '建立关联的操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关联记录创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_preload_loading_pair (tenant_id, inbound_id, loading_plan_id),
UNIQUE KEY uk_preload_loading_idempotency (created_by, idempotency_key),
@@ -2,42 +2,42 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL,
booking_no VARCHAR(40) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
customer_name VARCHAR(128) NOT NULL,
bill_of_lading_no VARCHAR(128) NULL,
shipping_company_id BIGINT UNSIGNED NULL,
shipping_company_name VARCHAR(128) NULL,
booking_agent_id BIGINT UNSIGNED NULL,
booking_agent_name VARCHAR(128) NULL,
vessel_name VARCHAR(128) NULL,
voyage_no VARCHAR(64) NULL,
departure_port_code VARCHAR(32) NULL,
departure_port_name VARCHAR(128) NULL,
destination_port_code VARCHAR(32) NULL,
destination_port_name VARCHAR(128) NULL,
place_of_delivery VARCHAR(128) NULL,
service_term VARCHAR(32) NULL,
freight_term VARCHAR(32) NULL,
estimated_sailing_at DATETIME(3) NULL,
estimated_arrival_at DATETIME(3) NULL,
pending_vessel_name VARCHAR(128) NULL,
pending_voyage_no VARCHAR(64) NULL,
pending_estimated_sailing_at DATETIME(3) NULL,
pending_estimated_arrival_at DATETIME(3) NULL,
change_reason VARCHAR(1000) NULL,
failure_reason VARCHAR(1000) NULL,
cancellation_reason VARCHAR(1000) NULL,
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照',
booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照',
booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照',
vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照',
voyage_no VARCHAR(64) NULL COMMENT '当前确认航次号快照',
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照',
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照',
service_term VARCHAR(32) NULL COMMENT '运输服务条款快照',
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
pending_voyage_no VARCHAR(64) NULL COMMENT '待确认变更航次号',
pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间',
pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间',
change_reason VARCHAR(1000) NULL COMMENT '船期变更原因',
failure_reason VARCHAR(1000) NULL COMMENT '订舱失败原因',
cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '订舱记录最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_no (booking_no),
KEY idx_ocean_booking_customer (customer_id,status,updated_at,id),
@@ -53,13 +53,13 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
party_type VARCHAR(24) NOT NULL COMMENT 'SHIPPER/CONSIGNEE/NOTIFY_PARTY',
party_name VARCHAR(255) NOT NULL,
address VARCHAR(1000) NULL,
contact_name VARCHAR(128) NULL,
contact_phone VARCHAR(64) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
party_type VARCHAR(24) NOT NULL COMMENT '关系方类型:SHIPPER发货人、CONSIGNEE收货人、NOTIFY_PARTY通知人',
party_name VARCHAR(255) NOT NULL COMMENT '关系方名称快照',
address VARCHAR(1000) NULL COMMENT '关系方地址快照',
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
@@ -67,12 +67,12 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
) ENGINE=InnoDB COMMENT='订舱提单关系方快照';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_type VARCHAR(32) NOT NULL,
container_kind VARCHAR(32) NOT NULL,
quantity INT UNSIGNED NOT NULL,
sort_no INT UNSIGNED NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '订舱箱型需求ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码',
container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码',
quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数',
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
@@ -80,13 +80,13 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
requirement_id BIGINT UNSIGNED NOT NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '订舱柜次分配关系ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
requirement_id BIGINT UNSIGNED NOT NULL COMMENT '对应订舱箱型需求ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID',
allocated_by BIGINT UNSIGNED NOT NULL,
allocated_by_name VARCHAR(64) NULL,
allocated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
allocated_by BIGINT UNSIGNED NOT NULL COMMENT '分配操作人ID',
allocated_by_name VARCHAR(64) NULL COMMENT '分配操作人姓名快照',
allocated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '柜次分配时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_allocation_container (container_booking_id),
KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at),
@@ -97,33 +97,33 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
) ENGINE=InnoDB COMMENT='订舱需求与现有柜次的唯一分配关系';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(24) NULL,
to_status VARCHAR(24) NOT NULL,
action VARCHAR(40) NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '订舱状态历史ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
from_status VARCHAR(24) NULL COMMENT '变更前订舱状态;创建时为空',
to_status VARCHAR(24) NOT NULL COMMENT '变更后订舱状态',
action VARCHAR(40) NOT NULL COMMENT '触发状态变更的业务动作',
reason VARCHAR(1000) NULL COMMENT '状态变更原因',
before_snapshot JSON NULL COMMENT '状态变更前订舱数据快照;创建时为空',
after_snapshot JSON NOT NULL COMMENT '状态变更后订舱数据快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间',
PRIMARY KEY (id),
KEY idx_ocean_history_booking (ocean_booking_id,created_at,id),
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)
) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(40) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '订舱写操作幂等记录ID',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱ID;创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写操作的平台操作人ID',
operation VARCHAR(40) NOT NULL COMMENT '幂等写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功',
result_json JSON NULL COMMENT '成功结果JSON快照;供幂等重放返回',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写操作成功完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key),
KEY idx_ocean_operation_booking (ocean_booking_id,created_at),
@@ -2,22 +2,22 @@
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单引用,不复制订单主数据',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单主对象',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '柜次引用',
status VARCHAR(32) NOT NULL DEFAULT 'READY_FOR_DISPATCH',
version INT UNSIGNED NOT NULL DEFAULT 0,
confirmed_by BIGINT UNSIGNED NULL,
confirmed_by_name VARCHAR(64) NULL,
confirmed_at DATETIME(3) NULL,
reversed_by BIGINT UNSIGNED NULL,
reversed_by_name VARCHAR(64) NULL,
reversed_at DATETIME(3) NULL,
remark VARCHAR(500) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '国内出仓调度记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order服务订单逻辑ID,不复制订单主数据',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联柜次逻辑ID',
status VARCHAR(32) NOT NULL DEFAULT 'READY_FOR_DISPATCH' COMMENT '出仓状态:READY_FOR_DISPATCH待确认出仓、WAITING_FOR_LOADING等待装柜',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认国内出仓的操作人ID',
confirmed_by_name VARCHAR(64) NULL COMMENT '确认国内出仓的操作人姓名快照',
confirmed_at DATETIME(3) NULL COMMENT '国内出仓确认时间',
reversed_by BIGINT UNSIGNED NULL COMMENT '撤销国内出仓的操作人ID',
reversed_by_name VARCHAR(64) NULL COMMENT '撤销国内出仓的操作人姓名快照',
reversed_at DATETIME(3) NULL COMMENT '国内出仓撤销时间',
remark VARCHAR(500) NULL COMMENT '国内出仓调度备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '出仓调度记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '出仓调度记录最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_outbound_dispatch_owner (tenant_id,inbound_id,container_booking_id),
KEY idx_outbound_dispatch_order (tenant_id,order_id,updated_at),
@@ -28,33 +28,33 @@ CREATE TABLE IF NOT EXISTS fl_outbound_dispatch (
) ENGINE=InnoDB COMMENT='三期国内出仓派车状态,不重复创建出仓单';
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_history (
id BIGINT UNSIGNED NOT NULL,
dispatch_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
remark VARCHAR(500) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '出仓状态历史记录ID',
dispatch_id BIGINT UNSIGNED NOT NULL COMMENT '关联国内出仓调度记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID快照',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联柜次逻辑ID快照',
action VARCHAR(32) NOT NULL COMMENT '出仓状态变更动作',
from_status VARCHAR(32) NULL COMMENT '变更前出仓状态;首次创建时为空',
to_status VARCHAR(32) NOT NULL COMMENT '变更后出仓状态',
remark VARCHAR(500) NULL COMMENT '状态变更原因或备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行状态变更的平台用户ID',
operator_name VARCHAR(64) NULL COMMENT '执行状态变更的平台用户姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间',
PRIMARY KEY (id),
KEY idx_outbound_dispatch_history (tenant_id,inbound_id,container_booking_id,created_at,id),
CONSTRAINT fk_outbound_dispatch_history FOREIGN KEY (dispatch_id) REFERENCES fl_outbound_dispatch(id)
) ENGINE=InnoDB COMMENT='国内出仓追加式审计历史';
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_operation (
id BIGINT UNSIGNED NOT NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '出仓幂等操作记录ID',
dispatch_id BIGINT UNSIGNED NULL COMMENT '首次声明幂等键时允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行写操作的平台用户ID',
operation VARCHAR(32) NOT NULL COMMENT '国内出仓写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等声明创建时间',
completed_at DATETIME(3) NULL COMMENT '幂等操作完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_outbound_dispatch_operation (operator_id,operation,idempotency_key),
KEY idx_outbound_dispatch_operation_resource (dispatch_id,created_at),
@@ -2,30 +2,46 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment (
pick_task_id BIGINT UNSIGNED NOT NULL,
assignee_id BIGINT UNSIGNED NOT NULL,
assignee_name VARCHAR(64) NOT NULL,
assigned_by BIGINT UNSIGNED NOT NULL,
assigned_by_name VARCHAR(64) NULL,
assignment_type VARCHAR(16) NOT NULL COMMENT 'CLAIMED/ASSIGNED',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '柜次PDA拣货任务ID;同一任务仅保留一名作业员',
assignee_id BIGINT UNSIGNED NOT NULL COMMENT '当前唯一作业员ID',
assignee_name VARCHAR(64) NOT NULL COMMENT '当前作业员姓名快照',
assigned_by BIGINT UNSIGNED NOT NULL COMMENT '领取任务的作业员或执行分配的平台用户ID',
assigned_by_name VARCHAR(64) NULL COMMENT '领取或分配操作人姓名快照',
assignment_type VARCHAR(16) NOT NULL COMMENT '分配方式:CLAIMED作业员领取、ASSIGNED平台指派',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,防止多人并发领取或覆盖',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务领取或分配时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '作业员分配最后更新时间',
PRIMARY KEY (pick_task_id),
KEY idx_loading_pick_assignee (assignee_id, updated_at),
CONSTRAINT fk_loading_pick_assignment_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT chk_loading_pick_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED'))
) ENGINE=InnoDB COMMENT='柜次PDA任务领取与作业员分配';
CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment (
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID,同时作为本表主键',
assignee_id BIGINT UNSIGNED NOT NULL COMMENT '当前领取或被分配的作业员ID',
assignee_name VARCHAR(64) NOT NULL COMMENT '当前作业员姓名快照',
assigned_by BIGINT UNSIGNED NOT NULL COMMENT '执行领取或分配操作的用户ID',
assigned_by_name VARCHAR(64) NULL COMMENT '执行领取或分配操作的用户姓名快照',
assignment_type VARCHAR(16) NOT NULL COMMENT '分配类型:CLAIMED作业员领取、ASSIGNED管理员分配',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细分配记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '明细分配记录最后更新时间',
PRIMARY KEY (pick_item_id),
KEY idx_loading_pick_item_assignee (assignee_id, updated_at),
CONSTRAINT fk_loading_pick_item_assignment_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id),
CONSTRAINT chk_loading_pick_item_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED'))
) ENGINE=InnoDB COMMENT='PDA拣货明细领取与作业员分配';
CREATE TABLE IF NOT EXISTS fl_loading_pick_review (
pick_task_id BIGINT UNSIGNED NOT NULL,
review_status VARCHAR(16) NOT NULL DEFAULT 'APPROVED',
reviewed_by BIGINT UNSIGNED NOT NULL,
reviewed_by_name VARCHAR(64) NULL,
remark VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '已完成的柜次PDA拣货任务ID',
review_status VARCHAR(16) NOT NULL DEFAULT 'APPROVED' COMMENT '复核状态:APPROVED已通过;记录创建后不可修改或删除',
reviewed_by BIGINT UNSIGNED NOT NULL COMMENT '执行完成复核的平台用户ID',
reviewed_by_name VARCHAR(64) NULL COMMENT '复核人姓名快照',
remark VARCHAR(500) NULL COMMENT 'PDA任务复核备注',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '复核记录版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '复核通过时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '复核记录最后更新时间;正常业务不允许更新',
PRIMARY KEY (pick_task_id),
KEY idx_loading_pick_reviewer (reviewed_by, created_at),
CONSTRAINT fk_loading_pick_review_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
@@ -33,14 +49,14 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_review (
) ENGINE=InnoDB COMMENT='柜次PDA任务完成复核';
CREATE TABLE IF NOT EXISTS fl_loading_pick_operation (
id BIGINT UNSIGNED NOT NULL,
pick_task_id BIGINT UNSIGNED NOT NULL,
operation_id VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
action_type VARCHAR(16) NOT NULL COMMENT 'CLAIM/ASSIGN/REVIEW',
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务操作审计记录ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '关联柜次PDA拣货任务ID',
operation_id VARCHAR(128) NOT NULL COMMENT '全局唯一操作ID,用于幂等重放',
request_hash CHAR(64) NOT NULL COMMENT '完整批量请求或任务操作内容SHA-256摘要',
action_type VARCHAR(16) NOT NULL COMMENT '动作类型:CLAIM领取、ASSIGN分配、REVIEW复核、BATCH批量提交',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行PDA任务操作的用户ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变操作审计创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_operation_id (operation_id),
KEY idx_loading_pick_operation_task (pick_task_id, created_at),
@@ -2,35 +2,35 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_port_schedule (
id BIGINT UNSIGNED NOT NULL,
schedule_no VARCHAR(40) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
source_type VARCHAR(24) NOT NULL COMMENT 'MANUAL/CARRIER/TERMINAL/API',
source_name VARCHAR(128) NOT NULL,
source_ref VARCHAR(255) NOT NULL,
vessel_name VARCHAR(128) NOT NULL,
voyage_no VARCHAR(64) NOT NULL,
departure_port_code VARCHAR(32) NOT NULL,
departure_port_name VARCHAR(128) NULL,
destination_port_code VARCHAR(32) NOT NULL,
destination_port_name VARCHAR(128) NULL,
etd_at DATETIME(3) NULL,
eta_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '港务船期记录ID',
schedule_no VARCHAR(40) NOT NULL COMMENT '港务船期记录编号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次ID;订舱整票船期时为空',
source_type VARCHAR(24) NOT NULL COMMENT '船期来源类型:MANUAL人工录入、CARRIER船公司、TERMINAL码头、API外部接口',
source_name VARCHAR(128) NOT NULL COMMENT '船期来源名称',
source_ref VARCHAR(255) NOT NULL COMMENT '来源侧记录唯一引用或查询凭据摘要',
vessel_name VARCHAR(128) NOT NULL COMMENT '船名快照',
voyage_no VARCHAR(64) NOT NULL COMMENT '航次号快照',
departure_port_code VARCHAR(32) NOT NULL COMMENT '起运港编码快照',
departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照',
destination_port_code VARCHAR(32) NOT NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
etd_at DATETIME(3) NULL COMMENT '预计离港时间',
eta_at DATETIME(3) NULL COMMENT '预计到港时间',
booking_cutoff_at DATETIME(3) NULL COMMENT '截单时间',
customs_cutoff_at DATETIME(3) NULL COMMENT '截关时间',
port_cutoff_at DATETIME(3) NULL COMMENT '截港时间',
observed_at DATETIME(3) NOT NULL COMMENT '来源数据观测时间',
expires_at DATETIME(3) NOT NULL COMMENT '超过该时间即由API计算为过期',
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '船期记录创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '船期记录最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_port_schedule_no (schedule_no),
UNIQUE KEY uk_port_schedule_source (source_type,source_name,source_ref),
@@ -53,17 +53,17 @@ CREATE TABLE IF NOT EXISTS fl_port_schedule (
) ENGINE=InnoDB COMMENT='独立港务船期来源聚合,不替代订舱快照';
CREATE TABLE IF NOT EXISTS fl_port_schedule_history (
id BIGINT UNSIGNED NOT NULL,
port_schedule_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(24) NOT NULL,
from_version INT UNSIGNED NULL,
to_version INT UNSIGNED NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '港务船期变更历史ID',
port_schedule_id BIGINT UNSIGNED NOT NULL COMMENT '所属港务船期记录ID',
action VARCHAR(24) NOT NULL COMMENT '变更动作:CREATE创建、UPDATE更新',
from_version INT UNSIGNED NULL COMMENT '变更前版本;创建时为空',
to_version INT UNSIGNED NOT NULL COMMENT '变更后版本',
reason VARCHAR(1000) NULL COMMENT '变更原因或说明',
before_snapshot JSON NULL COMMENT '变更前船期数据快照;创建时为空',
after_snapshot JSON NOT NULL COMMENT '变更后船期数据快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间',
PRIMARY KEY (id),
KEY idx_port_schedule_history (port_schedule_id,created_at,id),
CONSTRAINT fk_port_schedule_history FOREIGN KEY (port_schedule_id) REFERENCES fl_port_schedule(id),
@@ -71,16 +71,16 @@ CREATE TABLE IF NOT EXISTS fl_port_schedule_history (
) ENGINE=InnoDB COMMENT='港务船期不可变变更历史';
CREATE TABLE IF NOT EXISTS fl_port_schedule_operation (
id BIGINT UNSIGNED NOT NULL,
port_schedule_id BIGINT UNSIGNED NULL COMMENT '创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(24) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '港务船期写操作幂等记录ID',
port_schedule_id BIGINT UNSIGNED NULL COMMENT '关联港务船期ID;创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写操作的平台操作人ID',
operation VARCHAR(24) NOT NULL COMMENT '幂等写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功',
result_json JSON NULL COMMENT '成功结果JSON快照;供幂等重放返回',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写操作成功完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_port_schedule_operation (operator_id,idempotency_key),
KEY idx_port_schedule_operation_resource (port_schedule_id,created_at),
@@ -60,8 +60,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
@@ -77,8 +76,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)',
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
@@ -95,8 +93,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜计划最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no),
@@ -149,16 +146,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (
CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END
) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id),
@@ -185,15 +179,14 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
document_type VARCHAR(32) NOT NULL COMMENT 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER',
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:CUSTOMS_CLEARANCE清关文件、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_document_type (container_booking_id, document_type),
@@ -209,8 +202,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID',
action_type VARCHAR(32) NOT NULL
COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM',
action_type VARCHAR(32) NOT NULL COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NULL COMMENT '变更后状态',
before_snapshot JSON NULL COMMENT '变更前快照',
@@ -405,7 +397,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
@@ -428,12 +420,12 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '拣货明细状态:PENDING待拣、PICKING拣货中、PICKED已拣完、PARTIAL部分拣货、SKIPPED跳拣',
release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放',
release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id),
KEY idx_loading_pick_item_task (pick_task_id, item_status, id),
@@ -449,13 +441,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
request_hash CHAR(64) NOT NULL COMMENT '完整批量请求内容SHA-256摘要',
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT '拣货动作:PICK正常拣货、PARTIAL部分拣货、SKIP跳拣',
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
operator_name VARCHAR(64) NULL COMMENT '操作人名称',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行PDA拣货操作的作业员ID',
operator_name VARCHAR(64) NULL COMMENT '作业员姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拣货操作历史创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_history_operation (operation_id),
KEY idx_loading_pick_history_item (pick_item_id, created_at),
@@ -4,18 +4,18 @@ USE baiyue_firstleg;
/* 三期分柜与预装清单;独立于现有测量、计费表,可重复部署。 */
CREATE TABLE IF NOT EXISTS fl_preload_plan (
id BIGINT UNSIGNED NOT NULL COMMENT '预装清单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单/订单ID',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CONFIRMING/CONFIRMED',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
items_json JSON NOT NULL COMMENT '可编辑分柜明细快照',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '预装状态:PENDING待确认、CONFIRMING确认中、CONFIRMED已确认',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据',
remark VARCHAR(1000) NULL COMMENT '预装备注',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '预装清单创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '预装清单最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_preload_tenant_inbound (tenant_id, inbound_id),
KEY idx_preload_status (tenant_id, status, updated_at),
@@ -24,14 +24,14 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan (
CREATE TABLE IF NOT EXISTS fl_split_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(40) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCESS',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '操作目标入库单逻辑ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行分柜或预装写操作的平台用户ID',
operation VARCHAR(40) NOT NULL COMMENT '分柜或预装写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功',
result_json JSON NULL COMMENT '操作结果JSON快照,用于幂等重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等操作声明创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_split_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key),
KEY idx_split_operation_time (tenant_id, inbound_id, created_at),
@@ -96,25 +96,23 @@ DEALLOCATE PREPARE phase3_split_stmt;
CREATE TABLE IF NOT EXISTS fl_inbound_split_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键',
source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照',
locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数',
remaining_package_count INT UNSIGNED GENERATED ALWAYS AS (
source_package_count - locked_package_count
) STORED COMMENT '生成二级子单的剩余箱数',
status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED',
remaining_package_count INT UNSIGNED GENERATED ALWAYS AS (source_package_count - locked_package_count) STORED COMMENT '锁定后剩余箱数,由来源箱数减锁定箱数生成',
status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT '分柜明细状态:LOCKED已锁定、COMPLETED已完成拆分',
operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
completed_by BIGINT UNSIGNED NULL,
completed_by_name VARCHAR(64) NULL,
completed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建部分锁定明细的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
completed_by BIGINT UNSIGNED NULL COMMENT '完成拆分的操作人ID',
completed_by_name VARCHAR(64) NULL COMMENT '完成操作人姓名快照',
completed_at DATETIME(3) NULL COMMENT '部分拆分完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '部分锁定明细创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '部分锁定明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id),
UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id),
@@ -1,33 +1,64 @@
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS fl_truck_appointment (
id BIGINT UNSIGNED NOT NULL, appointment_no VARCHAR(40) NOT NULL, outbound_order_id BIGINT UNSIGNED NULL,
ocean_booking_id BIGINT UNSIGNED NULL, container_booking_id BIGINT UNSIGNED NULL, loading_plan_id BIGINT UNSIGNED NULL,
warehouse_id BIGINT UNSIGNED NULL, customer_id BIGINT UNSIGNED NULL, customer_name VARCHAR(128) NULL,
carrier_id BIGINT UNSIGNED NULL, carrier_name VARCHAR(128) NULL, fleet_contact VARCHAR(64) NULL, fleet_phone VARCHAR(32) NULL,
driver_name VARCHAR(64) NULL, driver_phone VARCHAR(32) NULL, driver_id_card VARCHAR(32) NULL, vehicle_no VARCHAR(32) NULL,
store_name VARCHAR(128) NULL, store_contact VARCHAR(64) NULL, store_phone VARCHAR(32) NULL, store_address VARCHAR(255) NULL,
store_city VARCHAR(64) NULL, store_zipcode VARCHAR(16) NULL, package_count INT UNSIGNED NULL, gross_weight_kg DECIMAL(18,3) NULL,
volume_cbm DECIMAL(18,4) NULL, pallet_count INT UNSIGNED NULL, po_no VARCHAR(255) NULL, planned_loading_at DATETIME(3) NULL,
actual_arrived_warehouse_at DATETIME(3) NULL, actual_loading_at DATETIME(3) NULL, actual_departed_at DATETIME(3) NULL, transit_minutes INT UNSIGNED NULL, payable_amount DECIMAL(18,2) NULL, receivable_amount DECIMAL(18,2) NULL,
currency VARCHAR(8) NOT NULL DEFAULT 'RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', revision INT UNSIGNED NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 0, remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no),
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约ID',
appointment_no VARCHAR(40) NOT NULL COMMENT '平台拖车预约编号',
outbound_order_id BIGINT UNSIGNED NULL COMMENT '关联头程订单逻辑ID;不跨库建外键',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验',
loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID',
warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID',
customer_id BIGINT UNSIGNED NULL COMMENT '客户ID和数据隔离键',
customer_name VARCHAR(128) NULL COMMENT '客户名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID',
carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照',
fleet_contact VARCHAR(64) NULL COMMENT '车队联系人',
fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话',
driver_name VARCHAR(64) NULL COMMENT '司机姓名',
driver_phone VARCHAR(32) NULL COMMENT '司机联系电话',
driver_id_card VARCHAR(32) NULL COMMENT '司机身份证号;平台内部敏感信息',
vehicle_no VARCHAR(32) NULL COMMENT '车辆车牌号',
store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照',
store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照',
store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照',
store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照',
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照',
store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照',
package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米',
pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量',
po_no VARCHAR(255) NULL COMMENT '关联采购单号或客户PO号',
planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间',
actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间',
actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用',
transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟',
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
receivable_amount DECIMAL(18,2) NULL COMMENT '向客户收取的拖车金额;币种由currency指定',
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), KEY idx_truck_appointment_customer(customer_id),
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED'))
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, revision INT UNSIGNED NOT NULL, action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NULL, reason VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL, request_id VARCHAR(128) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
from_status VARCHAR(32) NULL COMMENT '变更前拖车状态;创建时为空', to_status VARCHAR(32) NULL COMMENT '变更后拖车状态;改约时可保持原状态', reason VARCHAR(500) NULL COMMENT '状态或改约原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行操作的平台用户ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照', request_id VARCHAR(128) NULL COMMENT '请求链路中的请求ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, file_id BIGINT UNSIGNED NOT NULL, attachment_type VARCHAR(32) NOT NULL,
file_name VARCHAR(255) NULL, file_hash CHAR(64) NULL, visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL', uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id)
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型编码;由业务动作约定',
file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id)
) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, idempotency_key VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL, response_snapshot JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_operation(idempotency_key), KEY idx_truck_operation_appointment(appointment_id)
id BIGINT UNSIGNED NOT NULL COMMENT '拖车写操作幂等记录ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
action VARCHAR(32) NOT NULL COMMENT '执行的拖车业务动作', response_snapshot JSON NULL COMMENT '成功响应JSON快照;供相同幂等请求重放', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_operation(idempotency_key), KEY idx_truck_operation_appointment(appointment_id)
) ENGINE=InnoDB COMMENT='拖车操作幂等记录';
DROP PROCEDURE IF EXISTS phase3_truck_add_column;
@@ -54,11 +85,11 @@ DELIMITER ;
CALL phase3_truck_add_column(
'actual_arrived_warehouse_at',
'DATETIME(3) NULL AFTER planned_loading_at'
'DATETIME(3) NULL COMMENT ''车辆实际到达收货仓时间'' AFTER planned_loading_at'
);
CALL phase3_truck_add_column(
'actual_departed_at',
'DATETIME(3) NULL AFTER actual_loading_at'
'DATETIME(3) NULL COMMENT ''车辆实际离仓时间;DEPARTED后供LOADED门禁使用'' AFTER actual_loading_at'
);
DROP PROCEDURE IF EXISTS phase3_truck_add_column;
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceEventClientContractTest {
@Test
void usesFrozenFinanceInboxPathAndIdempotencyHeaders() {
FeignClient client = FinanceFeeSourceEventClient.class.getAnnotation(FeignClient.class);
assertThat(client.name()).isEqualTo("finance-service");
assertThat(client.path()).isEqualTo("/internal/finance/v1/fee-source-events");
var method = FinanceFeeSourceEventClient.class.getDeclaredMethods()[0];
assertThat(Arrays.stream(method.getParameterAnnotations())
.flatMap(Arrays::stream)
.filter(RequestHeader.class::isInstance)
.map(RequestHeader.class::cast)
.map(RequestHeader::value))
.contains("X-Internal-Service-Token", "Idempotency-Key");
}
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class InternalFinanceFeeSourceControllerTest {
private final FinanceFeeSourceSnapshotService service = mock(FinanceFeeSourceSnapshotService.class);
private final InternalFinanceFeeSourceController controller = controller();
@Test
void rejectsMissingOrWrongInternalTokenBeforeSnapshotLookup() {
assertThatThrownBy(() -> controller.inbound(null, 21L, 31L))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
assertThatThrownBy(() -> controller.container("wrong", 41L, 51L))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
}
@Test
void routesAuthorizedSnapshotLookup() {
controller.inbound("finance-token", 21L, 31L);
controller.container("finance-token", 41L, 51L);
verify(service).inbound(21L, 31L);
verify(service).container(41L, 51L);
}
@Test
void exposesFrozenInternalPaths() {
var mapping = InternalFinanceFeeSourceController.class
.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
assertThat(mapping.value()).containsExactly("/internal/firstleg/v1");
assertThat(methodPath("inbound")).isEqualTo("/inbounds/{inboundId}/fees/{feeId}/source-snapshot");
assertThat(methodPath("container")).isEqualTo("/containers/{containerId}/fees/{feeId}/source-snapshot");
}
private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
return value;
}
private static String methodPath(String name) {
return java.util.Arrays.stream(InternalFinanceFeeSourceController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow()
.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()[0];
}
}
@@ -8,6 +8,8 @@ import org.apache.ibatis.annotations.Select;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,6 +20,9 @@ class PdaConvergenceSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment")
.contains("assignment_type VARCHAR(16) NOT NULL COMMENT '分配类型:CLAIMED作业员领取、ASSIGNED管理员分配'")
.contains("CONSTRAINT fk_loading_pick_item_assignment_item")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
.contains("UNIQUE KEY uk_loading_pick_operation_id")
@@ -30,6 +35,11 @@ class PdaConvergenceSqlContractTest {
.contains("platform:firstleg:pda:assign")
.contains("platform:firstleg:pda:work")
.contains("platform:firstleg:pda:review");
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(createBlock(unified, "fl_loading_pick_item_assignment"))
.isEqualTo(createBlock(sql, "fl_loading_pick_item_assignment"));
}
@Test
@@ -51,4 +61,13 @@ class PdaConvergenceSqlContractTest {
.contains("ON DUPLICATE KEY UPDATE")
.contains("柜次PDA自动汇总");
}
private static String createBlock(String sql, String table) {
String marker = "CREATE TABLE IF NOT EXISTS " + table + " (";
int start = sql.indexOf(marker);
int end = sql.indexOf(";", start);
assertThat(start).as("missing table %s", table).isGreaterThanOrEqualTo(0);
assertThat(end).as("missing table terminator %s", table).isGreaterThan(start);
return sql.substring(start, end + 1).replace("\r\n", "\n");
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceMigrationContractTest {
@Test
void migrationDefinesIdempotentOutboxEnvelopeAndContainerFeeModel() throws Exception {
String sql = new ClassPathResource("db/finance-fee-source-migration.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"settlement_included", "source_version", "RECEIVABLE", "PAYABLE",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
}
}
@@ -5,11 +5,21 @@ import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class Phase3FinalUnifiedSqlContractTest {
private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
private static final Pattern CREATE_TABLE = Pattern.compile(
"(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(?:`[^`]+`\\.)?`?([a-zA-Z0-9_]+)`?\\s*\\(");
private static final Pattern COLUMN = Pattern.compile(
"(?i)^\\s*`?([a-zA-Z0-9_]+)`?\\s+(bigint|binary|bit|blob|boolean|char|date|datetime|decimal|double|enum|float|int|integer|json|longblob|longtext|mediumblob|mediumint|mediumtext|numeric|real|set|smallint|text|time|timestamp|tinyblob|tinyint|tinytext|varbinary|varchar|year)\\b");
private static final Pattern CHINESE_COMMENT = Pattern.compile(
"(?i)\\bCOMMENT\\s*=?\\s*'(?:(?:''|\\\\'|[^'])*[\\u3400-\\u9fff](?:''|\\\\'|[^'])*)'");
@Test
void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception {
@@ -27,6 +37,10 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
.contains("trg_inbound_split_detail_before_insert")
.contains("trg_inbound_split_detail_before_update")
.contains("trg_inbound_split_detail_before_delete")
.contains("CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH'))")
.contains("phase3_loading_evidence_add_column")
.contains("DROP INDEX uk_loading_evidence_type")
@@ -74,6 +88,86 @@ class Phase3FinalUnifiedSqlContractTest {
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
}
@Test
void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception {
String unified = normalise(Files.readString(resolveUnified()));
assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql",
"-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql",
"firstleg/src/main/resources/db/finance-fee-source-migration.sql");
assertBlock(unified, "-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql",
"-- END FINANCE FEE SOURCE MODULE MIGRATIONS",
"finance/src/main/resources/db/fee-source-event-migration.sql");
assertThat(unified)
.contains("column_name IN ('source_type','source_no','direction','container_id','fee_item_code',")
.contains("column_name IN ('payload_hash','result_resource_id','error_code')")
.contains("column_name IN ('event_key', 'event_version', 'aggregate_sequence', 'payload_hash',")
.contains("CREATE TABLE IF NOT EXISTS fl_container_fee_detail");
}
@Test
void commentUpgradeIsIdempotentAndGuardsNineSchemaStructure() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
assertThat(sql)
.contains("CREATE PROCEDURE queue_table_comment")
.contains("CREATE PROCEDURE queue_column_comment")
.contains("CREATE PROCEDURE apply_one_table_comments")
.contains("CREATE PROCEDURE apply_queued_comments")
.contains("NOT (BINARY v_current_comment=BINARY p_column_comment)")
.contains("SET v_signature_before=SHA2")
.contains("BINARY target.signature_before")
.contains("字段注释升级遇到未知数据类型")
.contains("字段注释升级遇到未知EXTRA定义")
.contains("字段注释升级遇到未知生成列结构")
.contains("MySQL DDL 会隐式提交")
.contains("不使用 GROUP_CONCAT")
.contains("DECLARE table_cursor CURSOR FOR")
.contains("CALL apply_one_table_comments(v_schema_name,v_table_name)")
.contains("CALL apply_queued_comments();")
.contains("PREPARE table_comment_batch_stmt FROM @table_comment_batch_sql")
.contains("九个业务数据库未完整创建")
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
assertThat(count(sql, "CALL queue_column_comment(")).isEqualTo(2456);
assertThat(sql.indexOf("CALL apply_queued_comments();",
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
}
@Test
void unifiedCreateDefinitionsHaveChineseTableAndColumnComments() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
List<String> issues = new ArrayList<>();
Matcher tableMatcher = CREATE_TABLE.matcher(sql);
int tableCount = 0;
int columnCount = 0;
while (tableMatcher.find()) {
tableCount++;
String table = tableMatcher.group(1);
int open = tableMatcher.end() - 1;
int close = matchingParen(sql, open);
int semicolon = sql.indexOf(';', close);
assertThat(semicolon).as("missing semicolon for %s", table).isGreaterThan(close);
if (!CHINESE_COMMENT.matcher(sql.substring(close + 1, semicolon)).find()) {
issues.add(table + ": table comment");
}
for (String definition : splitDefinitions(sql.substring(open + 1, close))) {
Matcher columnMatcher = COLUMN.matcher(definition);
if (!columnMatcher.find()) continue;
columnCount++;
if (!CHINESE_COMMENT.matcher(definition).find()) {
issues.add(table + "." + columnMatcher.group(1));
}
}
}
assertThat(tableCount).isGreaterThan(100);
assertThat(columnCount).isGreaterThan(2000);
assertThat(issues).isEmpty();
}
@Test
void passIsUniqueAndOnlyTheFinalExecutableStatement() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
@@ -153,4 +247,51 @@ class Phase3FinalUnifiedSqlContractTest {
private static int count(String value, String token) {
return value.split(java.util.regex.Pattern.quote(token), -1).length - 1;
}
private static int matchingParen(String sql, int open) {
int depth = 0;
char quote = 0;
for (int index = open; index < sql.length(); index++) {
char current = sql.charAt(index);
if (quote != 0) {
if (current == '\\') index++;
else if (current == quote) {
if (index + 1 < sql.length() && sql.charAt(index + 1) == quote) index++;
else quote = 0;
}
continue;
}
if (current == '\'' || current == '"' || current == '`') quote = current;
else if (current == '(') depth++;
else if (current == ')' && --depth == 0) return index;
}
throw new AssertionError("unclosed CREATE TABLE at " + open);
}
private static List<String> splitDefinitions(String body) {
List<String> definitions = new ArrayList<>();
int start = 0;
int depth = 0;
char quote = 0;
for (int index = 0; index < body.length(); index++) {
char current = body.charAt(index);
if (quote != 0) {
if (current == '\\') index++;
else if (current == quote) {
if (index + 1 < body.length() && body.charAt(index + 1) == quote) index++;
else quote = 0;
}
continue;
}
if (current == '\'' || current == '"' || current == '`') quote = current;
else if (current == '(') depth++;
else if (current == ')') depth--;
else if (current == ',' && depth == 0) {
definitions.add(body.substring(start, index));
start = index + 1;
}
}
definitions.add(body.substring(start));
return definitions;
}
}
@@ -0,0 +1,114 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ContainerFeeDetailServiceTest {
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class);
private final FeeItemRepository feeItems = mock(FeeItemRepository.class);
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
private final ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox);
@Test
void rejectsContainerThatDoesNotContainCustomerOrders() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity other = new LoadingPlanItemEntity();
other.setAccountId(20002L);
when(loading.items(41L)).thenReturn(List.of(other));
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, request(true)))
.isInstanceOf(BizException.class).hasMessageContaining("不属于该客户");
verify(repository, never()).insert(any());
}
@Test
void approvedSettlementFeePublishesContainerSource() {
ownedPlan();
ContainerFeeDetailEntity approved = new ContainerFeeDetailEntity();
approved.setId(51L);
approved.setTenantId(10001L);
approved.setLoadingPlanId(41L);
approved.setSourceVersion(1);
approved.setSettlementIncluded(true);
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
service.approve(10001L, 41L, 51L, 9L, "admin");
verify(outbox).publishContainer(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(41L), eq(51L), eq(1), any(LocalDateTime.class));
}
@Test
void adjustmentIncrementsSourceVersionAndReturnsToReview() {
ownedPlan();
FeeItemEntity item = item();
when(feeItems.findItem(11L)).thenReturn(item);
ContainerFeeDetailEntity current = new ContainerFeeDetailEntity();
current.setId(51L);
current.setTenantId(10001L);
current.setLoadingPlanId(41L);
current.setSourceVersion(1);
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
when(repository.adjust(any(), eq(1))).thenReturn(1);
service.adjust(10001L, 41L, 51L, 1, 9L, request(true));
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).adjust(captor.capture(), eq(1));
assertThat(captor.getValue().getSourceVersion()).isEqualTo(2);
assertThat(captor.getValue().getReviewStatus()).isEqualTo("PENDING");
}
private void ownedPlan() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity item = new LoadingPlanItemEntity();
item.setAccountId(10001L);
when(loading.items(41L)).thenReturn(List.of(item));
}
private static FeeItemEntity item() {
FeeItemEntity value = new FeeItemEntity();
value.setId(11L);
value.setFeeCode("TRUCKING");
value.setFeeTypeName("拖车费");
value.setWarehousePayable(true);
value.setStatus("ACTIVE");
return value;
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
settlementIncluded, "整柜拖车费");
}
}
@@ -42,6 +42,7 @@ import static org.mockito.Mockito.when;
@DisplayName("FBA费用状态机")
@SpringJUnitConfig(classes = {FbaPricingService.class, FbaPricingServiceTest.TestConfig.class})
class FbaPricingServiceTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
private static final long TENANT = 10001L;
private static final long OPERATOR = 20001L;
private static final long INBOUND = 30001L;
@@ -224,7 +225,9 @@ class FbaPricingServiceTest {
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET");
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET");
verify(repository, times(1)).insertOutbox(any());
verify(financeOutbox, times(1)).publishInbound(
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
eq(501L), eq(1), eq(NOW));
verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW);
verify(feeDetails, times(2)).publishFreight(TENANT, INBOUND, 501L, OPERATOR, null, NOW);
}
@@ -250,7 +253,8 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundFeeSent(TENANT, INBOUND, "INBOUNDED");
verify(repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
}
@Test
@@ -300,7 +304,8 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
assertThat(inbound.getProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).supersedeSent(TENANT, 501L);
verify(repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
}
private void readyRepository() {
@@ -27,6 +27,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
@@ -40,6 +41,8 @@ class FeeManagementServiceTest {
private FeeItemRepository repository;
@MockBean
private FbaPricingRepository pricingRepository;
@MockBean
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private FeeItemService feeItems;
@Resource
@@ -268,6 +271,54 @@ class FeeManagementServiceTest {
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
}
@Test
@DisplayName("审核通过且参与结算的人工费用写入Finance事件")
void publishesApprovedSettlementFee() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
approved.setTenantId(10001L);
approved.setInboundId(21L);
approved.setFeeItemId(11L);
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("作废已入池人工费用引用当前版本并发布VOIDED")
void voidsPublishedSettlementFee() {
InboundFeeDetailEntity current = new InboundFeeDetailEntity();
current.setId(31L);
current.setTenantId(10001L);
current.setInboundId(21L);
current.setFeeItemId(11L);
current.setSourceType("MANUAL");
current.setSourceFeeVersion(1);
current.setSettlementIncluded(true);
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
voided.setId(31L);
voided.setTenantId(10001L);
voided.setInboundId(21L);
voided.setFeeItemId(11L);
voided.setSourceType("MANUAL");
voided.setSourceFeeVersion(1);
voided.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided);
when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1);
details.voidApproved(10001L, 21L, 31L, 1, 9L);
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.VOIDED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
private static FeeItemDtos.SaveRequest itemRequest() {
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费",
false, false, true, true, true, "应付仓库租赁费");
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.FinanceFeeSourceEventClient;
import com.baiyue.firstleg.dto.FinanceFeeSourceEvent;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.repository.FinanceFeeSourceOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceFeeSourceOutboxRelayTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 11, 15, 0);
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private final FinanceFeeSourceOutboxRepository repository = mock(FinanceFeeSourceOutboxRepository.class);
private final FinanceFeeSourceEventClient client = mock(FinanceFeeSourceEventClient.class);
private final FinanceFeeSourceOutboxRelay relay = relay();
@Test
void claimsThenPublishesOnlyAfterFinanceAcknowledgesSameEvent() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope)).thenReturn(
ApiResponse.success(new FinanceFeeSourceEventClient.Acknowledgement(
901L)));
when(repository.published(row.getId(), "worker-1", NOW)).thenReturn(true);
relay.relay();
verify(repository).recoverStale(NOW.minusMinutes(5), NOW.plusSeconds(10), 12);
verify(repository).claim(row.getId(), "worker-1", NOW);
verify(client).receive("finance-token", envelope.idempotencyKey(), envelope);
verify(repository).published(row.getId(), "worker-1", NOW);
verify(repository, never()).retry(any(Long.class), any(), any(), any(), eq(false));
}
@Test
void concurrentWorkerThatLosesClaimDoesNotDeliver() {
InboundOutboxEntity row = event();
when(repository.pending(50, NOW)).thenReturn(List.of(row));
when(repository.claim(row.getId(), "worker-1", NOW)).thenReturn(false);
relay.relay();
verify(client, never()).receive(any(), any(), any());
verify(repository, never()).published(any(Long.class), any(), any());
}
@Test
void transientFailureReturnsClaimToPendingWithBackoff() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope))
.thenThrow(new IllegalStateException("finance unavailable"));
relay.relay();
verify(repository).retry(row.getId(), "worker-1", NOW.plusSeconds(10),
"finance unavailable", false);
verify(repository, never()).published(any(Long.class), any(), any());
}
@Test
void invalidEventHashFailsPermanentlyWithoutCallingFinance() {
InboundOutboxEntity row = event();
row.setPayloadHash("0".repeat(64));
ready(row);
relay.relay();
verify(repository).retry(eq(row.getId()), eq("worker-1"), eq(NOW.plusSeconds(10)),
any(), eq(true));
verify(client, never()).receive(any(), any(), any());
}
@Test
void financeIdempotencyConflictFailsPermanently() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope)).thenReturn(
new ApiResponse<>("FIN-EVT-002", "同一事件ID对应不同请求内容", null, "r", "t", Instant.now()));
relay.relay();
verify(repository).retry(eq(row.getId()), eq("worker-1"), eq(NOW.plusSeconds(10)),
any(), eq(true));
verify(repository, never()).published(any(Long.class), any(), any());
}
private void ready(InboundOutboxEntity row) {
when(repository.pending(50, NOW)).thenReturn(List.of(row));
when(repository.claim(row.getId(), "worker-1", NOW)).thenReturn(true);
when(repository.findClaimed(row.getId(), "worker-1")).thenReturn(row);
}
private InboundOutboxEntity event() {
InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
FinanceFeeSourceOutboxService producer = new FinanceFeeSourceOutboxService(mapper, json);
producer.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
10001L, 21L, 31L, 1, NOW);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
InboundOutboxEntity row = captor.getValue();
row.setId(71L);
return row;
}
private FinanceFeeSourceOutboxRelay relay() {
Clock clock = Clock.fixed(Instant.parse("2026-08-11T07:00:00Z"), ZoneId.of("Asia/Shanghai"));
FinanceFeeSourceOutboxRelay value =
new FinanceFeeSourceOutboxRelay(repository, client, json, clock, "worker-1");
ReflectionTestUtils.setField(value, "internalToken", "finance-token");
return value;
}
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class FinanceFeeSourceOutboxServiceTest {
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
private final FinanceFeeSourceOutboxService service =
new FinanceFeeSourceOutboxService(mapper, objectMapper);
@Test
void writesContractEnvelopeWithShortPayloadAndStableHash() {
LocalDateTime occurredAt = LocalDateTime.of(2026, 8, 11, 12, 0);
service.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
10001L, 21L, 31L, 1, occurredAt);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
InboundOutboxEntity event = captor.getValue();
assertThat(event.getEventType()).isEqualTo("FIRSTLEG_FEE_PUBLISHED");
assertThat(event.getEventVersion()).isEqualTo(1);
assertThat(event.getAggregateSequence()).isEqualTo(1);
assertThat(event.getPayloadHash()).matches("[0-9a-f]{64}");
assertThat(event.getPayloadJson()).contains("\"producer\":\"firstleg-service\"",
"\"inboundId\":\"21\"", "\"feeId\":\"31\"", "\"feeVersion\":1")
.doesNotContain("amount", "currency");
}
@Test
void treatsDuplicateEventKeyAsSuccessfulReplay() {
doThrow(new DuplicateKeyException("duplicate event_key")).when(mapper)
.insert((InboundOutboxEntity) org.mockito.ArgumentMatchers.any());
assertThatCode(() -> service.publishContainer(FinanceFeeSourceOutboxService.VOIDED,
10001L, 41L, 51L, 2, LocalDateTime.now())).doesNotThrowAnyException();
}
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class FinanceFeeSourceSnapshotServiceTest {
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class);
private final FinanceFeeSourceSnapshotService service =
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
@Test
void returnsReviewedSettlementSnapshotWithStableSourceHash() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setId(31L);
detail.setTenantId(10001L);
detail.setInboundId(21L);
detail.setFeeItemId(11L);
detail.setSourceType("MANUAL");
detail.setSourceFeeVersion(2);
detail.setDirection("RECEIVABLE");
detail.setAmount(new BigDecimal("100.00"));
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setPublicationStatus("PUBLISHED");
detail.setSettlementIncluded(true);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(21L);
inbound.setOrderId(81L);
inbound.setInboundCode("RK-21");
inbound.setCustomerName("客户A");
when(inboundFees.findInbound(10001L, 21L)).thenReturn(inbound);
FeeItemEntity item = new FeeItemEntity();
item.setId(11L);
item.setFeeCode("STORAGE");
when(inboundFees.findItem(11L)).thenReturn(item);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L);
assertThat(snapshot.customerId()).isEqualTo(10001L);
assertThat(snapshot.orderId()).isEqualTo(81L);
assertThat(snapshot.feeVersion()).isEqualTo(2);
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
detail.setPublicationStatus("VOIDED");
assertThat(service.inbound(21L, 31L).sourceHash()).isEqualTo(snapshot.sourceHash());
}
@Test
void rejectsFeeThatDoesNotBelongToInboundPath() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setInboundId(22L);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
assertThatThrownBy(() -> service.inbound(21L, 31L))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
}
@@ -25,6 +25,7 @@ import static org.mockito.Mockito.when;
@DisplayName("发送费用并入库状态编排")
@SpringJUnitConfig(InboundCompletionOrchestrator.class)
class InboundCompletionOrchestratorTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private InboundWorkflowRepository repository;
@MockBean
@@ -78,6 +79,11 @@ class InboundCompletionOrchestratorTest {
fixture.order.setProcessStatus("INBOUNDING");
when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(1);
when(fixture.repository.markFeeSent(10001L, 10L, 30L, 9L)).thenReturn(1);
com.baiyue.firstleg.entity.InboundFeeDetailEntity published =
new com.baiyue.firstleg.entity.InboundFeeDetailEntity();
published.setSourceFeeVersion(1);
when(feeDetails.publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class))).thenReturn(published);
fixture.service.completeSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 5, "complete-1");
@@ -86,7 +92,9 @@ class InboundCompletionOrchestratorTest {
verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L);
verify(feeDetails).publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
verify(fixture.repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(10L), eq(30L), eq(1),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test
@@ -102,7 +110,9 @@ class InboundCompletionOrchestratorTest {
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong());
verify(fixture.repository, never()).insertOutbox(any());
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test
+8 -8
View File
@@ -2,14 +2,14 @@ USE baiyue_iam;
-- 可靠审计本地 Outbox;与 IAM 业务表位于同一数据库。
CREATE TABLE IF NOT EXISTS sys_audit_outbox (
event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, resource_type VARCHAR(64) NOT NULL,
resource_id VARCHAR(128) NULL, service_name VARCHAR(64) NULL, payload_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', attempt_count INT NOT NULL DEFAULT 0,
compensation_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL, dead_at DATETIME(3) NULL,
sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型', resource_type VARCHAR(64) NOT NULL COMMENT '被审计资源类型',
resource_id VARCHAR(128) NULL COMMENT '被审计资源ID', service_name VARCHAR(64) NULL COMMENT '来源服务名称', payload_json JSON NOT NULL COMMENT '脱敏后的审计事件内容',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信', attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前重试周期发送次数',
compensation_count INT NOT NULL DEFAULT 0 COMMENT '自动补偿发送次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间', lease_owner VARCHAR(128) NULL COMMENT '当前租约持有者',
lease_until DATETIME(3) NULL COMMENT '发送租约截止时间', last_error VARCHAR(1000) NULL COMMENT '最近一次发送错误信息', dead_at DATETIME(3) NULL COMMENT '进入死信时间',
sent_at DATETIME(3) NULL COMMENT '成功发送时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间',
PRIMARY KEY (event_id), KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_audit_outbox_retention (status, sent_at)
) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';
+44 -44
View File
@@ -6,11 +6,11 @@ CREATE DATABASE IF NOT EXISTS baiyue_iam
USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_tenant (
id BIGINT NOT NULL COMMENT '租户ID(雪花ID)',
tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一租户编码',
tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '租户类型',
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED',
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码',
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用',
legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
@@ -40,13 +40,13 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
UNIQUE KEY uk_iam_tenant_code (tenant_code),
UNIQUE KEY uk_iam_tenant_credit (unified_social_credit_code),
KEY idx_iam_tenant_status (status, created_at)
) ENGINE=InnoDB COMMENT='企业租户主表:作为SaaS数据隔离边界,保存商户企业资料、审核结果和启停状态';
) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键';
CREATE TABLE IF NOT EXISTS iam_tenant_review (
id BIGINT NOT NULL COMMENT '审核记录ID',
tenant_id BIGINT NOT NULL COMMENT '被审核租户ID',
review_type VARCHAR(20) NOT NULL DEFAULT 'REGISTER' COMMENT '审核类型',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态',
tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键',
review_type VARCHAR(20) NOT NULL DEFAULT 'REGISTER' COMMENT '审核类型:REGISTER注册审核等',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
reviewer_id BIGINT NULL COMMENT '审核人ID',
review_comment VARCHAR(500) NULL COMMENT '审核意见',
reviewed_at DATETIME(3) NULL COMMENT '审核完成时间',
@@ -54,51 +54,51 @@ CREATE TABLE IF NOT EXISTS iam_tenant_review (
PRIMARY KEY (id),
KEY idx_review_queue (status, created_at),
KEY idx_review_tenant (tenant_id)
) ENGINE=InnoDB COMMENT='租户审核记录表:保存企业注册、资料变更等审核过程和审核意见';
) ENGINE=InnoDB COMMENT='客户审核记录表:保存企业注册、资料变更等审核过程和审核意见';
CREATE TABLE IF NOT EXISTS iam_tenant_address (
id BIGINT NOT NULL COMMENT '租户地址ID',
tenant_id BIGINT NOT NULL COMMENT '所属租户ID',
address_type VARCHAR(20) NOT NULL COMMENT '地址类型',
id BIGINT NOT NULL COMMENT '客户地址ID',
tenant_id BIGINT NOT NULL COMMENT '客户ID和数据隔离键',
address_type VARCHAR(20) NOT NULL COMMENT '地址类型:注册地址、办公地址等',
country_code CHAR(2) NOT NULL DEFAULT 'CN' COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省/州',
city VARCHAR(64) NULL COMMENT '城市',
district VARCHAR(64) NULL COMMENT '区县',
detail VARCHAR(255) NOT NULL COMMENT '详细地址',
postal_code VARCHAR(16) NULL COMMENT '邮政编码',
is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址',
is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址:0否、1是',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
PRIMARY KEY (id),
KEY idx_address_tenant (tenant_id, address_type)
) ENGINE=InnoDB COMMENT='租户地址表:保存企业注册地址、办公地址等租户级资料';
) ENGINE=InnoDB COMMENT='客户地址表:保存企业注册地址、办公地址等客户资料';
CREATE TABLE IF NOT EXISTS iam_tenant_qualification (
id BIGINT NOT NULL COMMENT '资质记录ID',
tenant_id BIGINT NOT NULL COMMENT '所属租户ID',
qualification_type VARCHAR(32) NOT NULL COMMENT '资质类型',
tenant_id BIGINT NOT NULL COMMENT '客户ID和数据隔离键',
qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型',
certificate_no VARCHAR(128) NULL COMMENT '证书编号',
document_url VARCHAR(500) NULL COMMENT '资质文件存储地址',
valid_from DATE NULL COMMENT '有效期开始日期',
valid_to DATE NULL COMMENT '有效期结束日期',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_comment VARCHAR(500) NULL COMMENT '审核意见',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
PRIMARY KEY (id),
KEY idx_qualification_tenant (tenant_id, status)
) ENGINE=InnoDB COMMENT='租户资质表:保存企业证照、有效期、文件地址和审核状态';
) ENGINE=InnoDB COMMENT='客户资质表:保存企业证照、有效期、文件地址和审核状态';
CREATE TABLE IF NOT EXISTS iam_user (
id BIGINT NOT NULL COMMENT 'IAM用户资料ID',
auth_user_id BIGINT NOT NULL COMMENT '认证服务账号逻辑ID',
user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型',
user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型:ENTERPRISE企业用户、PLATFORM平台用户',
display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称',
phone VARCHAR(32) NULL COMMENT '手机号',
email VARCHAR(128) NULL COMMENT '邮箱',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态:ACTIVE正常、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
@@ -109,14 +109,14 @@ CREATE TABLE IF NOT EXISTS iam_user (
) ENGINE=InnoDB COMMENT='IAM用户资料表:连接统一认证账号与业务身份资料,不保存密码';
CREATE TABLE IF NOT EXISTS iam_membership (
id BIGINT NOT NULL COMMENT '租户成员关系ID',
tenant_id BIGINT NOT NULL COMMENT '所属租户ID',
id BIGINT NOT NULL COMMENT '客户成员关系ID',
tenant_id BIGINT NOT NULL COMMENT '客户ID和数据隔离键',
user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID',
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型',
member_no VARCHAR(64) NULL COMMENT '租户内部成员编号',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态',
joined_at DATETIME(3) NULL COMMENT '加入租户时间',
left_at DATETIME(3) NULL COMMENT '离开租户时间',
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等',
member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开',
joined_at DATETIME(3) NULL COMMENT '加入客户企业时间',
left_at DATETIME(3) NULL COMMENT '离开客户企业时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
@@ -126,16 +126,16 @@ CREATE TABLE IF NOT EXISTS iam_membership (
UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no),
KEY idx_membership_user (user_id, status),
KEY idx_membership_tenant (tenant_id, status)
) ENGINE=InnoDB COMMENT='租户成员关系表:表示一个用户在某个企业租户中的成员身份,是分配商户端角色的主体';
) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体';
CREATE TABLE IF NOT EXISTS iam_role (
id BIGINT NOT NULL COMMENT '角色ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色所属租户;0表示平台预置角色',
app_code VARCHAR(20) NOT NULL COMMENT '应用端:MERCHANT或PLATFORM',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键',
app_code VARCHAR(20) NOT NULL COMMENT '应用端:MERCHANT企业客户端、PLATFORM平台后台',
role_code VARCHAR(64) NOT NULL COMMENT '作用域内唯一角色编码',
role_name VARCHAR(128) NOT NULL COMMENT '角色名称',
role_type VARCHAR(20) NOT NULL DEFAULT 'CUSTOM' COMMENT '角色类型:SYSTEM或CUSTOM',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '角色状态',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '角色状态:ACTIVE启用、DISABLED停用',
description VARCHAR(255) NULL COMMENT '角色用途说明',
created_by BIGINT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -146,21 +146,21 @@ CREATE TABLE IF NOT EXISTS iam_role (
PRIMARY KEY (id),
UNIQUE KEY uk_role_scope_code (tenant_id, app_code, role_code),
KEY idx_role_tenant (tenant_id, app_code, status)
) ENGINE=InnoDB COMMENT='角色表:按租户和应用端隔离商户端、平台后台两套角色体系';
) ENGINE=InnoDB COMMENT='角色表:按客户或平台作用域及应用端隔离企业客户端、平台后台两套角色体系';
CREATE TABLE IF NOT EXISTS iam_membership_role (
membership_id BIGINT NOT NULL COMMENT '租户成员关系ID',
membership_id BIGINT NOT NULL COMMENT '客户成员关系ID',
role_id BIGINT NOT NULL COMMENT '角色ID',
tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于租户隔离校验和查询',
tenant_id BIGINT NOT NULL COMMENT '冗余客户ID和数据隔离键,用于授权归属校验和查询',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间',
PRIMARY KEY (membership_id, role_id),
KEY idx_mr_tenant (tenant_id)
) ENGINE=InnoDB COMMENT='成员角色关联表:将租户成员分配到对应应用端的一个或多个角色';
) ENGINE=InnoDB COMMENT='成员角色关联表:将客户成员分配到对应应用端的一个或多个角色';
CREATE TABLE IF NOT EXISTS iam_permission (
id BIGINT NOT NULL COMMENT '权限资源ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '权限所属租户;0表示平台预置权限',
app_code VARCHAR(20) NOT NULL COMMENT '所属应用端:MERCHANT或PLATFORM',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '权限作用域:0为平台预置权限,正数为客户ID和数据隔离键',
app_code VARCHAR(20) NOT NULL COMMENT '所属应用端:MERCHANT企业客户端、PLATFORM平台后台',
permission_type VARCHAR(20) NOT NULL COMMENT '权限类型:MENU、PAGE、BUTTON或API',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父权限ID,用于构造菜单和页面树',
permission_code VARCHAR(128) NOT NULL COMMENT '鉴权使用的唯一权限码',
@@ -168,8 +168,8 @@ CREATE TABLE IF NOT EXISTS iam_permission (
route_path VARCHAR(255) NULL COMMENT '前端路由或API路径',
http_method VARCHAR(16) NULL COMMENT 'API权限对应的HTTP方法',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同级显示顺序',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态',
built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置权限不可通过管理接口修改',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态:ACTIVE启用、DISABLED停用',
built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限:0否、1是;预置权限不可通过管理接口修改',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
@@ -182,7 +182,7 @@ CREATE TABLE IF NOT EXISTS iam_permission (
CREATE TABLE IF NOT EXISTS iam_role_permission (
role_id BIGINT NOT NULL COMMENT '角色ID',
permission_id BIGINT NOT NULL COMMENT '权限资源ID',
tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于隔离校验和查询',
tenant_id BIGINT NOT NULL COMMENT '冗余客户ID或平台作用域ID,用于授权归属校验和查询',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间',
PRIMARY KEY (role_id, permission_id),
KEY idx_rp_tenant (tenant_id)
@@ -191,13 +191,13 @@ CREATE TABLE IF NOT EXISTS iam_role_permission (
CREATE TABLE IF NOT EXISTS iam_data_scope (
id BIGINT NOT NULL COMMENT '数据范围规则ID',
role_id BIGINT NOT NULL COMMENT '角色ID',
tenant_id BIGINT NOT NULL COMMENT '规则所属租户ID',
scope_type VARCHAR(20) NOT NULL DEFAULT 'SELF' COMMENT '范围类型:SELF、DEPARTMENT、TENANT或CUSTOM',
tenant_id BIGINT NOT NULL COMMENT '规则作用域:0为平台,正数为客户ID和数据隔离键',
scope_type VARCHAR(20) NOT NULL DEFAULT 'SELF' COMMENT '数据范围类型:SELF本人、DEPARTMENT部门、TENANT当前客户、CUSTOM自定义',
scope_expression JSON NULL COMMENT '自定义数据范围表达式',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_scope_role (role_id)
) ENGINE=InnoDB COMMENT='角色数据范围表:在功能权限之外限制角色可查看和操作的租户内数据集合';
) ENGINE=InnoDB COMMENT='角色数据范围表:在功能权限之外限制角色可查看和操作的平台或客户范围内数据集合';
-- 存量数据库重复执行初始化脚本时,不重复创建审核状态索引。
SET @review_status_index_sql = IF(
@@ -3,7 +3,7 @@ USE baiyue_iam;
SET NAMES utf8mb4;
ALTER TABLE iam_tenant MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED'
COMMENT 'PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED or DISABLED';
COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用';
DROP PROCEDURE IF EXISTS phase1_iam_add_column;
DELIMITER $$
CREATE PROCEDURE phase1_iam_add_column(
@@ -22,13 +22,13 @@ BEGIN
END IF;
END$$
DELIMITER ;
CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT 'Approval time'");
CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'");
CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'");
CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT '客户资料审核通过时间'");
CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT '客户资料审核人ID'");
CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT '客户资料审核驳回原因'");
CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name");
CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'");
CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'");
CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'");
CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT '客户资质审核意见'");
CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户成员关系创建人ID;系统创建时为0'");
CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户成员关系最后修改人ID;系统修改时为0'");
DROP PROCEDURE phase1_iam_add_column;
-- A global merchant owner template is assigned to newly registered enterprises.
+8 -8
View File
@@ -2,14 +2,14 @@ USE baiyue_order;
-- 可靠审计本地 Outbox;与订单业务表位于同一数据库。
CREATE TABLE IF NOT EXISTS sys_audit_outbox (
event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, resource_type VARCHAR(64) NOT NULL,
resource_id VARCHAR(128) NULL, service_name VARCHAR(64) NULL, payload_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', attempt_count INT NOT NULL DEFAULT 0,
compensation_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL, dead_at DATETIME(3) NULL,
sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型', resource_type VARCHAR(64) NOT NULL COMMENT '被审计资源类型',
resource_id VARCHAR(128) NULL COMMENT '被审计资源ID', service_name VARCHAR(64) NULL COMMENT '来源服务名称', payload_json JSON NOT NULL COMMENT '脱敏后的审计事件内容',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信', attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前重试周期发送次数',
compensation_count INT NOT NULL DEFAULT 0 COMMENT '自动补偿发送次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间', lease_owner VARCHAR(128) NULL COMMENT '当前租约持有者',
lease_until DATETIME(3) NULL COMMENT '发送租约截止时间', last_error VARCHAR(1000) NULL COMMENT '最近一次发送错误信息', dead_at DATETIME(3) NULL COMMENT '进入死信时间',
sent_at DATETIME(3) NULL COMMENT '成功发送时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间',
PRIMARY KEY (event_id), KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_audit_outbox_retention (status, sent_at)
) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';
+31 -31
View File
@@ -7,12 +7,12 @@ USE baiyue_order;
CREATE TABLE IF NOT EXISTS ord_order (
id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号',
inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消',
transport_mode VARCHAR(16) NOT NULL COMMENT '运输方式',
trade_term VARCHAR(16) NULL COMMENT '贸易条款',
destination_country_code CHAR(2) NOT NULL COMMENT '目的国家二字码',
@@ -33,10 +33,10 @@ CREATE TABLE IF NOT EXISTS ord_order (
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED或TAX_EXCLUDED;独立于tax_mode',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址',
amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码',
shipment_id VARCHAR(64) NULL COMMENT 'Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT 'Amazon Reference ID',
shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID',
order_role VARCHAR(12) NOT NULL DEFAULT 'MASTER' COMMENT '订单层级:MASTER主订单、CHILD子订单',
parent_order_id BIGINT UNSIGNED NULL COMMENT '直属父订单ID;主订单为空;禁止子订单嵌套',
root_order_id BIGINT UNSIGNED NULL COMMENT '根主订单ID',
@@ -61,25 +61,25 @@ CREATE TABLE IF NOT EXISTS ord_order (
CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID',
tenant_id BIGINT UNSIGNED NOT NULL,
parent_order_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
parent_order_id BIGINT UNSIGNED NOT NULL COMMENT '直属父订单ID;主订单作为拆分根',
source_order_id BIGINT UNSIGNED NOT NULL COMMENT '本次实际消费的主订单或余量子订单ID',
child_order_id BIGINT UNSIGNED NOT NULL,
split_role VARCHAR(12) NOT NULL COMMENT '本台账子片:LOADED或REMAINDER',
child_order_id BIGINT UNSIGNED NOT NULL COMMENT '本次分片承接的子订单ID',
split_role VARCHAR(12) NOT NULL COMMENT '本台账分片角色:LOADED已装柜部分、REMAINDER剩余部分',
loading_plan_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划逻辑ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT 'Firstleg柜次逻辑ID',
source_package_count INT UNSIGNED NOT NULL,
loaded_package_count INT UNSIGNED NOT NULL,
remaining_package_count INT UNSIGNED NOT NULL,
source_package_count INT UNSIGNED NOT NULL COMMENT '来源订单本次分片前的总包装件数',
loaded_package_count INT UNSIGNED NOT NULL COMMENT '本次已装柜分片包装件数',
remaining_package_count INT UNSIGNED NOT NULL COMMENT '本次分片后剩余包装件数',
source_detail_json JSON NULL COMMENT '来源货物明细及箱数快照',
operation_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
expected_parent_version INT NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
operation_id VARCHAR(64) NOT NULL COMMENT '履约分片业务操作ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要',
expected_parent_version INT NOT NULL COMMENT '执行前预期父订单乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建该分片台账的用户ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '台账创建时间',
version INT NOT NULL DEFAULT 0 COMMENT '台账乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_order_split_operation (tenant_id, operation_id, split_role),
UNIQUE KEY uk_order_split_idempotency (tenant_id, parent_order_id, idempotency_key, split_role),
@@ -127,7 +127,7 @@ DELIMITER ;
CREATE TABLE IF NOT EXISTS ord_order_party (
id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
party_type VARCHAR(16) NOT NULL COMMENT '参与方类型:SHIPPER或CONSIGNEE',
company_name VARCHAR(128) NOT NULL COMMENT '公司名称快照',
@@ -153,7 +153,7 @@ CREATE TABLE IF NOT EXISTS ord_order_party (
CREATE TABLE IF NOT EXISTS ord_order_cargo (
id BIGINT UNSIGNED NOT NULL COMMENT '货物明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
item_no INT NOT NULL COMMENT '订单内明细序号',
cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称',
@@ -169,8 +169,8 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
currency CHAR(3) NULL COMMENT '币种三字码',
dangerous_goods TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否危险品',
cargo_remark VARCHAR(500) NULL COMMENT '货物备注',
sku_no VARCHAR(64) NULL COMMENT 'SKU',
upc_no VARCHAR(64) NULL COMMENT 'UPC',
sku_no VARCHAR(64) NULL COMMENT '货物SKU编码',
upc_no VARCHAR(64) NULL COMMENT '货物UPC条码',
cargo_name_en VARCHAR(128) NULL COMMENT '英文品名',
customs_hs_code VARCHAR(20) NULL COMMENT '目的国清关HS CODE',
units_per_package INT UNSIGNED NULL COMMENT '每箱件数',
@@ -201,7 +201,7 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
CREATE TABLE IF NOT EXISTS ord_order_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
content VARCHAR(2000) NOT NULL COMMENT '仅内部人员可见的备注内容',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
@@ -217,7 +217,7 @@ CREATE TABLE IF NOT EXISTS ord_order_internal_note (
CREATE TABLE IF NOT EXISTS ord_order_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '状态历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态,首次创建可为空',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
@@ -233,12 +233,12 @@ CREATE TABLE IF NOT EXISTS ord_order_status_history (
CREATE TABLE IF NOT EXISTS ord_outbox_event (
id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID,同时作为消费幂等键',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属客户ID和数据隔离键',
aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型,例如 ORDER',
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '发生变更的聚合根ID,例如订单ID',
event_type VARCHAR(64) NOT NULL COMMENT '领域事件类型,例如 ORDER_SUBMITTED',
payload_json JSON NOT NULL COMMENT '供下游消费的事件负载快照',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态:PENDING、PUBLISHED或FAILED',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '投递失败后的累计重试次数',
next_retry_at DATETIME(3) NULL COMMENT '允许再次投递的最早时间',
published_at DATETIME(3) NULL COMMENT '成功发布到消息中间件的时间',
@@ -249,13 +249,13 @@ CREATE TABLE IF NOT EXISTS ord_outbox_event (
CREATE TABLE IF NOT EXISTS ord_idempotency_record (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作者ID',
operation VARCHAR(32) NOT NULL COMMENT '幂等操作类型',
resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源ID,创建草稿时为0',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256摘要',
status VARCHAR(16) NOT NULL COMMENT '处理状态:PROCESSING或COMPLETED',
status VARCHAR(16) NOT NULL COMMENT '处理状态:PROCESSING处理中、COMPLETED已完成',
result_order_id BIGINT UNSIGNED NULL COMMENT '成功结果订单ID',
completed_at DATETIME(3) NULL COMMENT '处理完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -25,41 +25,41 @@ BEGIN
END$$
DELIMITER ;
CALL phase1_add_column('ord_order', 'pickup_method', "VARCHAR(24) NULL COMMENT 'Pickup method'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_id', "BIGINT UNSIGNED NULL COMMENT 'Domestic receiving warehouse ID'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_name', "VARCHAR(128) NULL COMMENT 'Domestic receiving warehouse snapshot'");
CALL phase1_add_column('ord_order', 'pickup_method', "VARCHAR(24) NULL COMMENT '取货方式'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_id', "BIGINT UNSIGNED NULL COMMENT '国内收货仓逻辑ID'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_name', "VARCHAR(128) NULL COMMENT '下单时国内收货仓名称快照;主数据变化不回写'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_address', "VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照'");
CALL phase1_add_column('ord_order', 'receiving_time', "VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照'");
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'");
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'");
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'");
CALL phase1_add_column('ord_order', 'container_mode', "VARCHAR(24) NULL COMMENT 'FULL_CONTAINER or LCL'");
CALL phase1_add_column('ord_order', 'customs_inspection', "TINYINT(1) NULL COMMENT 'Whether customs inspection is required'");
CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMENT 'FBA, OVERSEAS or PRIVATE'");
CALL phase1_add_column('ord_order', 'amazon_warehouse_code', "VARCHAR(32) NULL COMMENT 'Amazon warehouse code'");
CALL phase1_add_column('ord_order', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Amazon Shipment ID'");
CALL phase1_add_column('ord_order', 'reference_id', "VARCHAR(64) NULL COMMENT 'Amazon Reference ID'");
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT '运输渠道编码'");
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT '下单时运输渠道名称快照;渠道主数据变化不回写'");
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT '报关税务方式:BUY_DECLARATION买单、TAX_REFUND退税'");
CALL phase1_add_column('ord_order', 'container_mode', "VARCHAR(24) NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱'");
CALL phase1_add_column('ord_order', 'customs_inspection', "TINYINT(1) NULL COMMENT '是否需要商检:0否、1是'");
CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址'");
CALL phase1_add_column('ord_order', 'amazon_warehouse_code', "VARCHAR(32) NULL COMMENT 'Amazon目的仓编码'");
CALL phase1_add_column('ord_order', 'shipment_id', "VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID'");
CALL phase1_add_column('ord_order', 'reference_id', "VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID'");
CALL phase1_add_column('ord_order', 'express_company', "VARCHAR(64) NULL COMMENT '履约快递公司名称快照'");
CALL phase1_add_column('ord_order', 'express_tracking_no', "VARCHAR(128) NULL COMMENT '履约快递单号'");
CALL phase1_add_column('ord_order_cargo', 'sku_no', "VARCHAR(64) NULL COMMENT 'SKU number'");
CALL phase1_add_column('ord_order_cargo', 'upc_no', "VARCHAR(64) NULL COMMENT 'UPC number'");
CALL phase1_add_column('ord_order_cargo', 'cargo_name_en', "VARCHAR(128) NULL COMMENT 'English cargo name'");
CALL phase1_add_column('ord_order_cargo', 'customs_hs_code', "VARCHAR(20) NULL COMMENT 'Destination customs HS code'");
CALL phase1_add_column('ord_order_cargo', 'units_per_package', "INT UNSIGNED NULL COMMENT 'Units per carton'");
CALL phase1_add_column('ord_order_cargo', 'total_quantity', "INT UNSIGNED NULL COMMENT 'Total units'");
CALL phase1_add_column('ord_order_cargo', 'net_weight_kg', "DECIMAL(18,3) NULL COMMENT 'Net weight in kilograms'");
CALL phase1_add_column('ord_order_cargo', 'unit_declared_value', "DECIMAL(18,2) NULL COMMENT 'Declared value per unit'");
CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT 'Material'");
CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'");
CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'");
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
CALL phase1_add_column('ord_order_cargo', 'product_length_cm', "DECIMAL(12,2) NULL COMMENT 'Product length in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'product_width_cm', "DECIMAL(12,2) NULL COMMENT 'Product width in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NULL COMMENT 'Product height in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'");
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'");
CALL phase1_add_column('ord_order_cargo', 'sku_no', "VARCHAR(64) NULL COMMENT '货物SKU编码'");
CALL phase1_add_column('ord_order_cargo', 'upc_no', "VARCHAR(64) NULL COMMENT '货物UPC条码'");
CALL phase1_add_column('ord_order_cargo', 'cargo_name_en', "VARCHAR(128) NULL COMMENT '货物英文品名'");
CALL phase1_add_column('ord_order_cargo', 'customs_hs_code', "VARCHAR(20) NULL COMMENT '目的国清关HS编码'");
CALL phase1_add_column('ord_order_cargo', 'units_per_package', "INT UNSIGNED NULL COMMENT '每箱产品数量'");
CALL phase1_add_column('ord_order_cargo', 'total_quantity', "INT UNSIGNED NULL COMMENT '产品总数量'");
CALL phase1_add_column('ord_order_cargo', 'net_weight_kg', "DECIMAL(18,3) NULL COMMENT '货物净重,单位千克'");
CALL phase1_add_column('ord_order_cargo', 'unit_declared_value', "DECIMAL(18,2) NULL COMMENT '单件申报价值;币种取订单申报币种'");
CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT '产品材质'");
CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT '产品用途'");
CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT '产品型号'");
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT '产品品牌'");
CALL phase1_add_column('ord_order_cargo', 'product_length_cm', "DECIMAL(12,2) NULL COMMENT '单件产品长度,单位厘米'");
CALL phase1_add_column('ord_order_cargo', 'product_width_cm', "DECIMAL(12,2) NULL COMMENT '单件产品宽度,单位厘米'");
CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NULL COMMENT '单件产品高度,单位厘米'");
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Shipment ID'");
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'File服务中的产品图片逻辑文件ID'");
-- 货物级字段上线前,FBA 编号只保存在主单;回填后提交校验可统一按每条货物执行。
UPDATE ord_order_cargo cargo
@@ -75,15 +75,15 @@ DROP PROCEDURE phase1_add_column;
-- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。
CREATE TABLE IF NOT EXISTS ord_idempotency_record (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录 ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户 ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人 ID',
operation VARCHAR(32) NOT NULL COMMENT '操作类型',
resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源 ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写操作的用户ID',
operation VARCHAR(32) NOT NULL COMMENT '幂等操作类型',
resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作目标资源ID;创建前未知时为0',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(16) NOT NULL COMMENT '处理状态',
result_order_id BIGINT UNSIGNED NULL COMMENT '重放结果订单 ID',
completed_at DATETIME(3) NULL COMMENT '完成时间',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要,用于识别同键不同请求',
status VARCHAR(16) NOT NULL COMMENT '处理状态:PROCESSING处理中、COMPLETED已完成',
result_order_id BIGINT UNSIGNED NULL COMMENT '成功操作生成或更新的订单ID,用于幂等重放',
completed_at DATETIME(3) NULL COMMENT '处理完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
@@ -12,7 +12,7 @@ BEGIN
END IF;
END$$
DELIMITER ;
CALL phase3_order_add_column('ord_order','order_role',"VARCHAR(12) NOT NULL DEFAULT 'MASTER' COMMENT '订单层级:MASTER、CHILD'");
CALL phase3_order_add_column('ord_order','order_role',"VARCHAR(12) NOT NULL DEFAULT 'MASTER' COMMENT '订单层级:MASTER主订单、CHILD子订单'");
CALL phase3_order_add_column('ord_order','parent_order_id','BIGINT UNSIGNED NULL COMMENT ''直属父订单ID;禁止子订单嵌套''');
CALL phase3_order_add_column('ord_order','root_order_id','BIGINT UNSIGNED NULL COMMENT ''根主订单ID''');
CALL phase3_order_add_column('ord_order','split_sequence','INT UNSIGNED NULL COMMENT ''同一父订单下的同级顺序''');
@@ -48,26 +48,26 @@ CALL phase3_order_parent_fk();
DROP PROCEDURE IF EXISTS phase3_order_parent_fk;
CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
parent_order_id BIGINT UNSIGNED NOT NULL,
source_order_id BIGINT UNSIGNED NOT NULL,
child_order_id BIGINT UNSIGNED NOT NULL,
split_role VARCHAR(12) NOT NULL COMMENT 'LOADED或REMAINDER',
loading_plan_id BIGINT UNSIGNED NULL,
container_booking_id BIGINT UNSIGNED NULL,
source_package_count INT UNSIGNED NOT NULL,
loaded_package_count INT UNSIGNED NOT NULL,
remaining_package_count INT UNSIGNED NOT NULL,
source_detail_json JSON NULL,
operation_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
expected_parent_version INT NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '订单履约分片台账ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
parent_order_id BIGINT UNSIGNED NOT NULL COMMENT '直属父订单ID;主订单自身作为拆分根',
source_order_id BIGINT UNSIGNED NOT NULL COMMENT '实际被本次分片消费的来源订单ID',
child_order_id BIGINT UNSIGNED NOT NULL COMMENT '本次分片生成或承接的子订单ID',
split_role VARCHAR(12) NOT NULL COMMENT '分片角色:LOADED已装柜部分、REMAINDER剩余部分',
loading_plan_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划逻辑ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT 'Firstleg柜次逻辑ID',
source_package_count INT UNSIGNED NOT NULL COMMENT '来源订单本次分片前的总包装件数',
loaded_package_count INT UNSIGNED NOT NULL COMMENT '本次已装柜分片的包装件数',
remaining_package_count INT UNSIGNED NOT NULL COMMENT '本次分片后剩余包装件数',
source_detail_json JSON NULL COMMENT '来源货物明细及数量快照;来源订单变化不回写',
operation_id VARCHAR(64) NOT NULL COMMENT '履约分片业务操作ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要',
expected_parent_version INT NOT NULL COMMENT '执行前预期父订单乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建该分片台账的用户ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '台账创建时间',
version INT NOT NULL DEFAULT 0 COMMENT '台账乐观锁版本号',
PRIMARY KEY(id),
UNIQUE KEY uk_order_split_operation (tenant_id,operation_id,split_role),
UNIQUE KEY uk_order_split_idempotency (tenant_id,parent_order_id,idempotency_key,split_role),
@@ -115,7 +115,7 @@ BEGIN
END$$
DELIMITER ;
CALL phase3_order_add_column('ord_order_fulfillment_split','split_role',"VARCHAR(12) NOT NULL DEFAULT 'LOADED' COMMENT 'LOADED或REMAINDER'");
CALL phase3_order_add_column('ord_order_fulfillment_split','split_role',"VARCHAR(12) NOT NULL DEFAULT 'LOADED' COMMENT '分片角色:LOADED已装柜部分、REMAINDER剩余部分'");
CALL phase3_order_add_column('ord_order_fulfillment_split','source_order_id','BIGINT UNSIGNED NULL COMMENT ''实际被消费的来源订单ID''');
CALL phase3_order_add_column('ord_order_fulfillment_split','request_hash',"CHAR(64) NULL COMMENT '请求内容SHA-256;存量记录允许为空'");
-- 旧版台账没有来源字段;当时只允许从根主订单拆分,因此可无歧义回填为父订单。
+16 -1
View File
@@ -44,9 +44,24 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令
## 现有相关脚本与边界
### 数据库中文注释审计
`database-comment-audit.ps1` 离线扫描各模块 `src/main/resources/db/*.sql` 中的 `CREATE TABLE` 定义,不连接数据库、不执行 SQL,也不修改任何文件。
```powershell
.\scripts\database-comment-audit.ps1
.\scripts\database-comment-audit.ps1 -Modules finance,order
.\scripts\database-comment-audit.ps1 -Modules finance -SummaryOnly
.\scripts\database-comment-audit.ps1 -Format Json
.\scripts\database-comment-audit.ps1 -FailOnIssues
.\scripts\database-comment-audit.ps1 -SelfTest
```
默认报告模式即使存在存量缺口也返回 `0`;CI 使用 `-FailOnIssues`,缺失或纯英文表/字段注释返回 `1`,模块或路径配置错误返回 `2`。规范和注释口径见 `../docs/数据库注释维护规范.md`。
### 本地 MySQL 全量验收
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `E86BB8DE105E9B59736FFCD180CBA902F2E3F5032C7EFF2E3DC3B24B289360A0` 和 7803 行。
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `7F61C3BAB0D4D49525A30C1E5030DEA4B798F38F6A0287901E6D1D676967DFFA` 和 13712 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
```powershell
.\local-db-full-acceptance.ps1 -DryRun
+230
View File
@@ -0,0 +1,230 @@
[CmdletBinding()]
param(
[string]$RepositoryRoot,
[string[]]$Modules,
[ValidateSet("Text", "Json")]
[string]$Format = "Text",
[switch]$SummaryOnly,
[switch]$FailOnIssues,
[switch]$SelfTest
)
$ErrorActionPreference = "Stop"
function Get-SqlComment {
param([string]$Text)
$match = [regex]::Match($Text, "(?is)\bCOMMENT\s*(?:=\s*)?'(?<value>(?:''|[^'])*)'")
if (-not $match.Success) {
return $null
}
return $match.Groups["value"].Value.Replace("''", "'").Trim()
}
function Get-CreateTableDefinitions {
param(
[string]$Sql,
[string]$Source
)
$pattern = '(?is)\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<name>`?[A-Za-z_][A-Za-z0-9_$]*`?)\s*\((?<body>.*?)\)\s*(?<tail>ENGINE\s*=.*?);'
$mysqlColumnTypes = @(
"BIGINT", "INT", "INTEGER", "MEDIUMINT", "SMALLINT", "TINYINT",
"DECIMAL", "NUMERIC", "FIXED", "FLOAT", "DOUBLE", "REAL", "BIT", "BOOL", "BOOLEAN",
"CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "BINARY", "VARBINARY",
"TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT",
"TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB", "ENUM", "SET",
"DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", "JSON",
"GEOMETRY", "POINT", "LINESTRING", "POLYGON", "MULTIPOINT",
"MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION"
)
$matches = [regex]::Matches($Sql, $pattern)
$definitions = @()
foreach ($match in $matches) {
$columns = @()
foreach ($line in ($match.Groups["body"].Value -split "`r?`n")) {
$columnMatch = [regex]::Match($line, '^\s*(?<name>`?[A-Za-z_][A-Za-z0-9_$]*`?)\s+(?<definition>[A-Za-z].*?)\s*,?\s*$')
if (-not $columnMatch.Success) {
continue
}
$definition = $columnMatch.Groups["definition"].Value
$typeMatch = [regex]::Match($definition, '^(?<type>[A-Za-z]+)\b')
if (-not $typeMatch.Success -or $typeMatch.Groups["type"].Value.ToUpperInvariant() -notin $mysqlColumnTypes) {
continue
}
$columnName = $columnMatch.Groups["name"].Value.Trim('`')
$columns += [pscustomobject]@{
Name = $columnName
Comment = Get-SqlComment -Text $line
}
}
$definitions += [pscustomobject]@{
Source = $Source
Table = $match.Groups["name"].Value.Trim('`')
Comment = Get-SqlComment -Text $match.Groups["tail"].Value
Columns = $columns
}
}
return $definitions
}
function Test-ChineseComment {
param([AllowNull()][string]$Comment)
return -not [string]::IsNullOrWhiteSpace($Comment) -and $Comment -match '[\p{IsCJKUnifiedIdeographs}]'
}
function Get-AuditResult {
param([object[]]$Definitions)
$issues = @()
$tableCount = 0
$commentedTableCount = 0
$columnCount = 0
$commentedColumnCount = 0
foreach ($definition in $Definitions) {
$tableCount++
if (-not [string]::IsNullOrWhiteSpace($definition.Comment)) {
$commentedTableCount++
}
if ([string]::IsNullOrWhiteSpace($definition.Comment)) {
$issues += [pscustomobject]@{ Code = "MISSING_TABLE_COMMENT"; Source = $definition.Source; Table = $definition.Table; Column = $null }
} elseif (-not (Test-ChineseComment -Comment $definition.Comment)) {
$issues += [pscustomobject]@{ Code = "NON_CHINESE_TABLE_COMMENT"; Source = $definition.Source; Table = $definition.Table; Column = $null }
}
foreach ($column in $definition.Columns) {
$columnCount++
if (-not [string]::IsNullOrWhiteSpace($column.Comment)) {
$commentedColumnCount++
}
if ([string]::IsNullOrWhiteSpace($column.Comment)) {
$issues += [pscustomobject]@{ Code = "MISSING_COLUMN_COMMENT"; Source = $definition.Source; Table = $definition.Table; Column = $column.Name }
} elseif (-not (Test-ChineseComment -Comment $column.Comment)) {
$issues += [pscustomobject]@{ Code = "NON_CHINESE_COLUMN_COMMENT"; Source = $definition.Source; Table = $definition.Table; Column = $column.Name }
}
}
}
$tableCoverage = if ($tableCount -eq 0) { 100 } else { [math]::Round(100 * $commentedTableCount / $tableCount, 2) }
$columnCoverage = if ($columnCount -eq 0) { 100 } else { [math]::Round(100 * $commentedColumnCount / $columnCount, 2) }
return [pscustomobject]@{
Summary = [pscustomobject]@{
TableDefinitions = $tableCount
CommentedTables = $commentedTableCount
TableCoveragePercent = $tableCoverage
Columns = $columnCount
CommentedColumns = $commentedColumnCount
ColumnCoveragePercent = $columnCoverage
Issues = $issues.Count
}
Issues = $issues
}
}
function Invoke-SelfTest {
$validSql = @"
CREATE TABLE IF NOT EXISTS sample_valid (
id BIGINT NOT NULL COMMENT '主键ID',
status VARCHAR(16) NOT NULL COMMENT '状态:ACTIVE启用、DISABLED停用',
amount DECIMAL(20,6) NOT NULL COMMENT '含税金额;币种由currency_code指定',
and_flag TINYINT(1) NOT NULL COMMENT '附加条件标记:0否、1是',
CONSTRAINT chk_sample_status CHECK (
status IN ('ACTIVE', 'DISABLED')
AND amount >= 0
OR (status = 'DISABLED' AND amount = 0)
)
) ENGINE=InnoDB COMMENT='自测业务表';
"@
$invalidSql = @"
CREATE TABLE sample_invalid (
id BIGINT NOT NULL,
payload JSON NULL COMMENT 'JSON payload'
) ENGINE=InnoDB COMMENT='Sample table';
"@
$valid = Get-AuditResult -Definitions @(Get-CreateTableDefinitions -Sql $validSql -Source "valid.sql")
if ($valid.Summary.TableDefinitions -ne 1 -or $valid.Summary.Columns -ne 4 -or $valid.Summary.Issues -ne 0) {
throw "Self-test failed: valid SQL was not parsed as expected."
}
$invalid = Get-AuditResult -Definitions @(Get-CreateTableDefinitions -Sql $invalidSql -Source "invalid.sql")
$codes = @($invalid.Issues | ForEach-Object { $_.Code })
foreach ($expected in @("NON_CHINESE_TABLE_COMMENT", "MISSING_COLUMN_COMMENT", "NON_CHINESE_COLUMN_COMMENT")) {
if ($expected -notin $codes) {
throw "Self-test failed: missing expected issue $expected."
}
}
Write-Output "SELF-TEST PASS: parser, coverage and Chinese-comment checks are working."
}
if ($SelfTest) {
Invoke-SelfTest
exit 0
}
if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
$RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
} else {
$RepositoryRoot = (Resolve-Path $RepositoryRoot).Path
}
$dbDirectories = @(Get-ChildItem -LiteralPath $RepositoryRoot -Directory | ForEach-Object {
$candidate = Join-Path $_.FullName "src/main/resources/db"
if (Test-Path -LiteralPath $candidate -PathType Container) {
[pscustomobject]@{ Module = $_.Name; Path = $candidate }
}
})
if ($Modules -and $Modules.Count -gt 0) {
$requested = @($Modules | ForEach-Object { $_.ToLowerInvariant() })
$dbDirectories = @($dbDirectories | Where-Object { $_.Module.ToLowerInvariant() -in $requested })
$found = @($dbDirectories | ForEach-Object { $_.Module.ToLowerInvariant() })
$missingModules = @($requested | Where-Object { $_ -notin $found })
if ($missingModules.Count -gt 0) {
Write-Error ("Unknown modules or missing db directories: " + ($missingModules -join ", "))
exit 2
}
}
$definitions = @()
foreach ($dbDirectory in ($dbDirectories | Sort-Object Module)) {
foreach ($file in (Get-ChildItem -LiteralPath $dbDirectory.Path -Filter "*.sql" -File | Sort-Object FullName)) {
$relativePath = $file.FullName.Substring($RepositoryRoot.Length + 1).Replace('\', '/')
$sql = Get-Content -Raw -LiteralPath $file.FullName
$definitions += @(Get-CreateTableDefinitions -Sql $sql -Source $relativePath)
}
}
$result = Get-AuditResult -Definitions $definitions
if ($Format -eq "Json") {
$jsonValue = if ($SummaryOnly) { $result.Summary } else { $result }
Write-Output ($jsonValue | ConvertTo-Json -Depth 6)
} else {
Write-Output "Database comment audit"
Write-Output "Repository: $RepositoryRoot"
Write-Output "Database/API access: none"
Write-Output ("Table definitions: {0}, commented: {1}, coverage: {2}%" -f $result.Summary.TableDefinitions, $result.Summary.CommentedTables, $result.Summary.TableCoveragePercent)
Write-Output ("Columns: {0}, commented: {1}, coverage: {2}%" -f $result.Summary.Columns, $result.Summary.CommentedColumns, $result.Summary.ColumnCoveragePercent)
Write-Output ("Issues: {0}" -f $result.Summary.Issues)
if (-not $SummaryOnly) {
foreach ($issue in $result.Issues) {
$target = if ([string]::IsNullOrWhiteSpace($issue.Column)) { $issue.Table } else { "$($issue.Table).$($issue.Column)" }
Write-Output ("[{0}] {1} ({2})" -f $issue.Code, $target, $issue.Source)
}
}
}
if ($FailOnIssues -and $result.Summary.Issues -gt 0) {
exit 1
}
exit 0
+30 -2
View File
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
$ExpectedSha256 = 'E86BB8DE105E9B59736FFCD180CBA902F2E3F5032C7EFF2E3DC3B24B289360A0'
$ExpectedLines = 7803
$ExpectedSha256 = '03234DF522426213FA64768FDF35182DC315F2E358E5FD993AA24A2E99936D63'
$ExpectedLines = 13775
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
@@ -32,6 +32,25 @@ if ($actual -ne $ExpectedSha256 -or $lines -ne $ExpectedLines) {
[Console]::Error.WriteLine("Unified SQL snapshot mismatch: $actual/$lines")
exit 2
}
$sqlText = [System.IO.File]::ReadAllText($InputPath)
$commentGateTokens = @(
'CREATE PROCEDURE queue_column_comment',
'CREATE PROCEDURE apply_one_table_comments',
'CALL apply_queued_comments();',
'字段注释批量升级改变了结构签名',
'存在缺失中文注释的业务表',
'存在缺失中文注释的业务字段'
)
foreach ($token in $commentGateTokens) {
if (-not $sqlText.Contains($token)) {
[Console]::Error.WriteLine("Unified SQL comment acceptance marker missing: $token")
exit 2
}
}
if ([regex]::Matches($sqlText, "SELECT 'PASS' AS migration_status").Count -ne 1) {
[Console]::Error.WriteLine('Unified SQL must contain exactly one final PASS statement')
exit 2
}
$sequence = @(
'empty: base.sql', 'replay: base.sql + input.sql + input.sql',
@@ -40,11 +59,20 @@ $sequence = @(
'overseas-missing-objects: fixture-overseas-missing-objects.sql',
'interrupted-recovery: fixture-interrupted.sql', 'final-structure: verify-final.sql')
Write-Output "SQL SHA-256: $actual / lines: $lines"
Write-Output 'Comment gate: queued validation, per-table ALTER, signature verification, nine-schema coverage, unique PASS'
Write-Output "Target: $TargetHost`:$Port"
Write-Output 'Fixture order:'
$sequence | ForEach-Object { Write-Output " $_" }
if ($DryRun) { Write-Output 'DRY-RUN PASS (mysql not started; no database writes)'; exit 0 }
$acceptanceInput = Join-Path $Acceptance 'input.sql'
Copy-Item -LiteralPath $InputPath -Destination $acceptanceInput -Force
$stagedHash = ((Get-FileHash -Algorithm SHA256 -LiteralPath $acceptanceInput).Hash).Trim().ToUpperInvariant()
if ($stagedHash -ne $actual) {
[Console]::Error.WriteLine("Failed to stage the validated unified SQL snapshot: $stagedHash")
exit 2
}
$run = 0
function Invoke-Stage([string]$Name, [string]$File) {
$script = Join-Path $Acceptance $File
@@ -2,14 +2,14 @@ USE baiyue_warehouse;
-- 可靠审计本地 Outbox;与仓储业务表位于同一数据库。
CREATE TABLE IF NOT EXISTS sys_audit_outbox (
event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, resource_type VARCHAR(64) NOT NULL,
resource_id VARCHAR(128) NULL, service_name VARCHAR(64) NULL, payload_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', attempt_count INT NOT NULL DEFAULT 0,
compensation_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL, dead_at DATETIME(3) NULL,
sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型', resource_type VARCHAR(64) NOT NULL COMMENT '被审计资源类型',
resource_id VARCHAR(128) NULL COMMENT '被审计资源ID', service_name VARCHAR(64) NULL COMMENT '产生事件的服务名', payload_json JSON NOT NULL COMMENT '待投递审计事件载荷',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、PROCESSING处理中、SENT已发送、FAILED失败', attempt_count INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数',
compensation_count INT NOT NULL DEFAULT 0 COMMENT '补偿处理次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许重试时间', lease_owner VARCHAR(128) NULL COMMENT '当前租约持有实例',
lease_until DATETIME(3) NULL COMMENT '当前租约截止时间', last_error VARCHAR(1000) NULL COMMENT '最近一次发送错误', dead_at DATETIME(3) NULL COMMENT '进入死信时间',
sent_at DATETIME(3) NULL COMMENT '发送成功时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (event_id), KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_audit_outbox_retention (status, sent_at)
) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';
@@ -1,25 +1,25 @@
SET NAMES utf8mb4;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_partner (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围',
partner_code VARCHAR(50) NOT NULL, partner_name VARCHAR(128) NOT NULL,
partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称',
partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH',
contact_name VARCHAR(64) NOT NULL, contact_phone VARCHAR(32) NOT NULL,
contact_email VARCHAR(128) NULL, country_code CHAR(2) NOT NULL,
province VARCHAR(64) NULL, city VARCHAR(64) NOT NULL, district VARCHAR(64) NULL,
address_line VARCHAR(255) NOT NULL, postal_code VARCHAR(20) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_by BIGINT UNSIGNED NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县',
address_line VARCHAR(255) NOT NULL COMMENT '详细地址', postal_code VARCHAR(20) NULL COMMENT '邮政编码',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted)
) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据';
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
data_scope_id BIGINT UNSIGNED NOT NULL, partner_type VARCHAR(32) NOT NULL,
partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围,平台公共数据为0', partner_type VARCHAR(32) NOT NULL COMMENT '稳定业务类型:CUSTOMER客户、SHIPPER发货方等',
PRIMARY KEY(partner_id,partner_type),
KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id)
) ENGINE=InnoDB COMMENT='平台共享往来单位稳定业务类型';

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