Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b077c20db | ||
|
|
0192380e81 | ||
|
|
8540147992 | ||
|
|
db80701534 | ||
|
|
274a3f34c5 | ||
|
|
5bc62a109e | ||
|
|
af646c6f1f | ||
|
|
50251240f4 | ||
|
|
808fabaf41 | ||
|
|
073cb6a577 | ||
|
|
cca1bc002e | ||
|
|
8ba04fc87d | ||
|
|
8cf6709bde | ||
|
|
f779ab56dd | ||
|
|
9eaaa9a0a0 | ||
|
|
8ff49b501f | ||
|
|
6ad438e733 | ||
|
|
885ff9dc9e | ||
|
|
e9564a21a2 | ||
|
|
c43d581ba6 | ||
|
|
b004edaf9e | ||
|
|
b780d8bad2 | ||
|
|
38e8494216 | ||
|
|
bd523036d0 | ||
|
|
6d10f9e502 | ||
|
|
226ef6a839 | ||
|
|
8bcd23f307 | ||
|
|
8b393d1906 | ||
|
|
319900e359 | ||
|
|
ef9852e757 | ||
|
|
939e90912a | ||
|
|
65cd7e0a39 | ||
|
|
bc62650ca9 | ||
|
|
18cebd3fdd | ||
|
|
1df3805334 | ||
|
|
a16b41eb8c | ||
|
|
e5fda0899e | ||
|
|
756a6b13eb | ||
|
|
e742abd1cf | ||
|
|
16dc03a2e2 | ||
|
|
5a48992d78 | ||
|
|
c331133016 | ||
|
|
af75569c78 | ||
|
|
43bbd9c1e8 | ||
|
|
74edf76e72 |
@@ -27,3 +27,5 @@ reports/
|
||||
uploads/
|
||||
.codex-temp*
|
||||
.codex-tmp*
|
||||
.codex-*.out
|
||||
.codex-*.err
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
||||
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
|
||||
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
|
||||
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
|
||||
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
||||
@@ -39,17 +42,20 @@
|
||||
## 契约、数据库与验证入口
|
||||
|
||||
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
|
||||
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
||||
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
||||
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
|
||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
|
||||
## 全量测试脚本路由
|
||||
## 全量 HTTP 验收脚本路由
|
||||
|
||||
- 本地后端静态/单元回归:在 `baiyue-firstleg/` 执行 `pwsh scripts/backend-full-regression.ps1 -DryRun` 先检查覆盖文件,再按需去掉 `-DryRun` 执行 `order + firstleg` Maven 测试;脚本不访问数据库、Gateway 或测试环境。
|
||||
- 本地前端回归:在 `baiyue-firstleg/` 执行 `pwsh scripts/frontend-full-regression.ps1 -DryRun` 先检查入口,再按需执行 Admin/Client 的 `pnpm test` 和 `pnpm typecheck`;脚本不启动开发服务器、不访问 Gateway。
|
||||
- 测试环境 API 全量验收:`scripts/admin-test-env-acceptance.mjs`、`scripts/client-test-env-acceptance.mjs`、`scripts/phase2-test-env-acceptance.ps1` 及 `scripts/admin-test-env-modules/` 属于有数据写入的场景脚本,会创建测试客户、订单或业务记录;只有用户在当前对话明确授权测试环境验收时才执行,并使用报告目录留存结果。
|
||||
- `scripts/local-db-full-acceptance.ps1` 仅允许 `127.0.0.1`/`localhost` 数据库,先用 `-DryRun` 校验统一 SQL 快照和注释门禁;不得改参数连接远程或测试数据库。
|
||||
- 验收脚本按“先本地静态回归,再测试环境 API 验收,最后人工浏览器样式验收”顺序使用;脚本通过不等于页面视觉、权限和真实数据链路全部通过。
|
||||
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
|
||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||
|
||||
## 阶段文档入口
|
||||
|
||||
|
||||
Vendored
+17
-3
@@ -24,6 +24,8 @@ def commonPaths = [
|
||||
'Jenkinsfile.service', 'deploy/'
|
||||
]
|
||||
|
||||
def databaseMigrationPaths = ['deploy/sql/']
|
||||
|
||||
def downstreamResults = [:]
|
||||
|
||||
pipeline {
|
||||
@@ -83,6 +85,7 @@ pipeline {
|
||||
error('DOWNSTREAM_JOB_PREFIX 不能为空')
|
||||
}
|
||||
env.RESOLVED_JOB_PREFIX = jobPrefix
|
||||
env.DATABASE_CHANGE_DETECTED = 'false'
|
||||
|
||||
List selected = []
|
||||
if (params.BUILD_MODE == 'ALL') {
|
||||
@@ -101,8 +104,13 @@ pipeline {
|
||||
} else {
|
||||
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
||||
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
||||
boolean databaseChanged = changed.any { path ->
|
||||
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
}
|
||||
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
|
||||
boolean commonChanged = changed.any { path ->
|
||||
commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||
}
|
||||
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
||||
changed.any { it == module || it.startsWith("${module}/") }
|
||||
@@ -119,13 +127,19 @@ pipeline {
|
||||
env.SELECTED_SERVICES = selected.join(',')
|
||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||
if (!selected) {
|
||||
currentBuild.description = '没有需要构建的业务服务'
|
||||
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
|
||||
? '仅数据库迁移变更 | 需单独审核执行'
|
||||
: '没有需要构建的业务服务'
|
||||
} else {
|
||||
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}"
|
||||
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
|
||||
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
||||
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
||||
}
|
||||
if (env.DATABASE_CHANGE_DETECTED == 'true') {
|
||||
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -110,7 +110,14 @@ pipeline {
|
||||
stage('Maven 测试与打包') {
|
||||
steps {
|
||||
script {
|
||||
String testOption = params.SKIP_TESTS ? '-DskipTests' : ''
|
||||
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
||||
error('生产环境禁止跳过测试')
|
||||
}
|
||||
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
|
||||
String testOption = skipTestExecution ? '-DskipTests' : ''
|
||||
if (params.ENVIRONMENT == 'test') {
|
||||
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
|
||||
}
|
||||
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|---|---|
|
||||
| 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` |
|
||||
| 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) |
|
||||
| 平台日志查询 API | `controller/PlatformAuditLogController.java`(`/api/platform/v1/audit/logs`)、`service/AuditLogQueryService.java`;列表与详情统一使用 `platform:audit:list` |
|
||||
| 接收 DTO | `dto/AuditIngestRequest.java` |
|
||||
| 入库编排 | `service/AuditIngestService.java` |
|
||||
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
|
||||
@@ -23,5 +24,6 @@
|
||||
- `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。
|
||||
- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。
|
||||
- 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。
|
||||
- 平台日志列表不得返回变更前后快照,详情才返回已脱敏 JSON;客户账号不得访问平台查询接口。
|
||||
- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。
|
||||
- 当前没有专属自动化测试;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
|
||||
- 自动化测试位于 `src/test/java/com/baiyue/audit/`,覆盖平台日志查询、Controller 权限、MQ 接收/死信和持久化;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
|
||||
|
||||
+3
-2
@@ -12,7 +12,8 @@
|
||||
|---|---|
|
||||
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
||||
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||
| Token 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java` |
|
||||
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
|
||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||
| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
|
||||
@@ -21,7 +22,7 @@
|
||||
|
||||
## 修改约束
|
||||
|
||||
- 密码、Token、敏感 Claim 不得写日志;修改 Claim、Redis Key 或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
|
||||
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
|
||||
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
||||
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 国内与海外 JWT/JWKS 冻结契约
|
||||
|
||||
## 双轨边界
|
||||
|
||||
### 国内现有链路
|
||||
|
||||
- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。
|
||||
- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。
|
||||
- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。
|
||||
- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。
|
||||
- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。
|
||||
|
||||
### 海外尾程链路
|
||||
|
||||
- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。
|
||||
- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。
|
||||
- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。
|
||||
- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。
|
||||
- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。
|
||||
|
||||
## 海外签发契约
|
||||
|
||||
- `issuer`:`auth-service`
|
||||
- `audience`:`lastleg-api`
|
||||
- 算法白名单:仅 `RS256`
|
||||
- Access Token 默认有效期:15 分钟
|
||||
- 兑换接口:`POST /api/auth/overseas/token`
|
||||
- JWKS 路径:`/api/auth/.well-known/jwks.json`
|
||||
- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json`
|
||||
- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300`
|
||||
|
||||
海外 Token 的 JSON 声明名称和类型固定如下:
|
||||
|
||||
| 声明 | JSON 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `sub` | string | 用户 ID 的字符串形式 |
|
||||
| `jti` | string | Token 唯一标识 |
|
||||
| `iat` | number | 签发时间 |
|
||||
| `exp` | number | 过期时间 |
|
||||
| `aud` | string array | 固定包含 `lastleg-api` |
|
||||
| `userId` | number | 认证用户 ID |
|
||||
| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 |
|
||||
| `appCode` | string | `PLATFORM` 或 `MERCHANT` |
|
||||
| `roles` | string array | 当前角色编码快照 |
|
||||
| `authorities` | string array | 当前权限编码快照 |
|
||||
| `permissionVersion` | number | IAM 单调权限版本 |
|
||||
|
||||
海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。
|
||||
|
||||
## 海外配置
|
||||
|
||||
```yaml
|
||||
baiyue:
|
||||
security:
|
||||
overseas-jwt:
|
||||
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||
```
|
||||
|
||||
只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。
|
||||
|
||||
## 密钥轮换
|
||||
|
||||
- JWK Set 只允许 RSA 密钥。
|
||||
- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。
|
||||
- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。
|
||||
- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。
|
||||
- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。
|
||||
|
||||
## IAM 安全事件
|
||||
|
||||
安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。
|
||||
@@ -20,4 +20,5 @@ public class AuthorizationSnapshot {
|
||||
private String clientType;
|
||||
private Set<String> roles;
|
||||
private Set<String> permissions;
|
||||
private Long permissionVersion;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import com.baiyue.common.security.JwtAuthenticationFilter;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
|
||||
@@ -31,6 +27,7 @@ public class AuthSecurityConfig {
|
||||
"/api/auth/refresh",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/impersonations/exchange",
|
||||
"/api/auth/.well-known/jwks.json",
|
||||
"/actuator/health",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
@@ -40,13 +37,6 @@ public class AuthSecurityConfig {
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
||||
|
||||
@Bean
|
||||
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
|
||||
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
||||
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
|
||||
JwtAuthenticationFilter jwtFilter,
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.time.Duration;
|
||||
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer,
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
|
||||
Duration accessTokenTtl, Duration refreshTokenTtl,
|
||||
int maxLoginFailures, Duration lockDuration) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class DomesticJwtConfiguration {
|
||||
@Bean("jwtEncoder")
|
||||
@Primary
|
||||
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
|
||||
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
||||
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(OverseasJwtProperties.class)
|
||||
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||
public class OverseasJwtConfiguration {
|
||||
@Bean("overseasJwtEncoder")
|
||||
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
|
||||
try {
|
||||
JWKSet set = JWKSet.parse(properties.rsaJwkSet());
|
||||
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
|
||||
if (current == null || !current.isPrivate()) {
|
||||
throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥");
|
||||
}
|
||||
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
|
||||
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
|
||||
}
|
||||
if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
|
||||
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) {
|
||||
throw new IllegalStateException("海外非当前密钥只能配置公钥");
|
||||
}
|
||||
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
|
||||
} catch (java.text.ParseException exception) {
|
||||
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** 海外尾程 RS256 短时令牌配置,默认关闭。 */
|
||||
@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt")
|
||||
public record OverseasJwtProperties(boolean enabled, String issuer, String audience,
|
||||
String rsaJwkSet, String currentKid, Duration accessTokenTtl) { }
|
||||
@@ -23,6 +23,8 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import com.baiyue.auth.service.OverseasTokenService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
/**
|
||||
@@ -38,6 +40,18 @@ public class AuthController {
|
||||
private MerchantRegistrationService registrationService;
|
||||
@Resource
|
||||
private AccountManagementService accountManagementService;
|
||||
@Resource
|
||||
private ObjectProvider<OverseasTokenService> overseasTokens;
|
||||
|
||||
@PostMapping("/overseas/token")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
|
||||
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
|
||||
return ApiResponse.success(service.exchange(user.userId()));
|
||||
}
|
||||
|
||||
@PutMapping("/password")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||
public class JwksController {
|
||||
private final OverseasJwtProperties properties;
|
||||
|
||||
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
|
||||
|
||||
@GetMapping("/api/auth/.well-known/jwks.json")
|
||||
@SecurityRequirements
|
||||
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
|
||||
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
|
||||
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
|
||||
@@ -108,7 +109,8 @@ public class AuthService {
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.subject(account.getId().toString())
|
||||
.claim("user_id", account.getId())
|
||||
.claim("username", account.getUsername())
|
||||
@@ -119,9 +121,13 @@ public class AuthService {
|
||||
.claim("client_type", authorization.getClientType())
|
||||
.claim("token_version", account.getTokenVersion())
|
||||
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
|
||||
.claim("userId", account.getId())
|
||||
.claim("tenantId", account.getDefaultTenantId())
|
||||
.claim("appCode", account.getDefaultAppCode())
|
||||
.claim("permissionVersion", account.getTokenVersion())
|
||||
.build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
||||
signingHeader(), claims)).getTokenValue();
|
||||
|
||||
byte[] random = new byte[48];
|
||||
secureRandom.nextBytes(random);
|
||||
@@ -167,16 +173,20 @@ public class AuthService {
|
||||
Instant issuedAt = Instant.now();
|
||||
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()).id(jwtId)
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
|
||||
.audience(List.of(properties.audience())).id(jwtId)
|
||||
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
|
||||
.claim("user_id", account.getId()).claim("username", account.getUsername())
|
||||
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
|
||||
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
|
||||
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
|
||||
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
|
||||
.claim("appCode", "MERCHANT")
|
||||
.claim("permissionVersion", account.getTokenVersion())
|
||||
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
|
||||
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
|
||||
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
|
||||
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
|
||||
authorization.getPermissions(), Duration.ofMinutes(15));
|
||||
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
|
||||
@@ -184,6 +194,10 @@ public class AuthService {
|
||||
|
||||
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
||||
|
||||
private JwsHeader signingHeader() {
|
||||
return JwsHeader.with(MacAlgorithm.HS256).build();
|
||||
}
|
||||
|
||||
private static BizException invalidCredentials() {
|
||||
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||
public class OverseasTokenService {
|
||||
@Resource private AuthAccountRepository accounts;
|
||||
@Resource private AuthorizationLoader authorizationLoader;
|
||||
@Resource private OverseasJwtProperties properties;
|
||||
@Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder;
|
||||
|
||||
public OverseasTokenResponse exchange(long authenticatedUserId) {
|
||||
AuthAccount account = accounts.findById(authenticatedUserId);
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
|
||||
}
|
||||
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||
Instant issuedAt = Instant.now();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
||||
.issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
|
||||
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
|
||||
.claim("authorities", authorization.getPermissions())
|
||||
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
|
||||
.build();
|
||||
String token = encoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
||||
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
|
||||
}
|
||||
|
||||
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
||||
}
|
||||
@@ -34,10 +34,18 @@ baiyue.security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
||||
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:24h}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
|
||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
||||
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
||||
overseas-jwt:
|
||||
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||
|
||||
baiyue.clients:
|
||||
iam:
|
||||
|
||||
@@ -72,6 +72,6 @@ class AuthorizationLoaderTest {
|
||||
|
||||
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
|
||||
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
|
||||
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"));
|
||||
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -15,8 +15,9 @@ class AuthTokenTtlConfigurationContractTest {
|
||||
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(configuration)
|
||||
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:2h}")
|
||||
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
|
||||
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
|
||||
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
|
||||
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DualTrackJwtConfigurationTest {
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
|
||||
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
|
||||
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
|
||||
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
|
||||
java.time.Duration.ofMinutes(15)))
|
||||
.withPropertyValues(
|
||||
"baiyue.security.overseas-jwt.enabled=false");
|
||||
|
||||
@Test
|
||||
void startsDomesticEncoderWithoutRsaConfiguration() {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).hasBean("jwtEncoder");
|
||||
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
|
||||
assertThat(context).hasSingleBean(JwtEncoder.class);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.KeyUse;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.util.Base64URL;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class JwksControllerTest {
|
||||
@Test
|
||||
void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception {
|
||||
RSAKey current = key("current", true);
|
||||
RSAKey previous = key("previous", false);
|
||||
String json = new JWKSet(List.of(current, previous)).toString(false);
|
||||
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
|
||||
"current", Duration.ofMinutes(15));
|
||||
|
||||
var body = new JwksController(properties).jwks().getBody();
|
||||
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
|
||||
|
||||
assertThat(published).contains("current", "previous", "\"n\"", "\"e\"")
|
||||
.doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"");
|
||||
}
|
||||
|
||||
private RSAKey key(String kid, boolean privatePart) {
|
||||
RSAKey.Builder builder = new RSAKey.Builder(
|
||||
Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17)))
|
||||
.keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256);
|
||||
if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753)));
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
@@ -39,13 +40,14 @@ class AuthServiceSmallJwtTest {
|
||||
static class TestConfig {
|
||||
@Bean
|
||||
AuthSecurityProperties properties() {
|
||||
return new AuthSecurityProperties("unused", "auth-service", Duration.ofMinutes(30),
|
||||
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api",
|
||||
Duration.ofMinutes(30),
|
||||
Duration.ofDays(7), 5, Duration.ofMinutes(10));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signsIdentityOnlyJwtAndStoresAuthorizationInRedis() {
|
||||
void signsCrossRegionClaimsAndStoresSession() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(42L);
|
||||
account.setUsername("operator");
|
||||
@@ -72,12 +74,57 @@ class AuthServiceSmallJwtTest {
|
||||
verify(jwtEncoder).encode(jwt.capture());
|
||||
assertThat(jwt.getValue().getClaims().getClaims())
|
||||
.containsEntry("user_id", 42L)
|
||||
.containsEntry("userId", 42L)
|
||||
.containsEntry("tenantId", 0L)
|
||||
.containsEntry("appCode", "PLATFORM")
|
||||
.containsEntry("permissionVersion", 1)
|
||||
.containsEntry("membership_id", 84L)
|
||||
.doesNotContainKeys("roles", "permissions", "authorities", "scope");
|
||||
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(43L);
|
||||
account.setUsername("merchant-owner");
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setDefaultTenantId(10001L);
|
||||
account.setOperatorTenantId(0L);
|
||||
account.setDefaultMembershipId(85L);
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setTokenVersion(2);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
|
||||
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
|
||||
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
|
||||
|
||||
when(tokenSessionStore.consumeImpersonationTicket("one-time-code")).thenReturn(ticket);
|
||||
when(repository.findById(43L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
|
||||
|
||||
service.exchangeImpersonationTicket("one-time-code");
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(jwtEncoder).encode(jwt.capture());
|
||||
assertThat(jwt.getValue().getClaims().getClaims())
|
||||
.containsEntry("appCode", "MERCHANT")
|
||||
.containsEntry("impersonated", true)
|
||||
.containsEntry("membership_id", 85L)
|
||||
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
|
||||
@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true")
|
||||
class OverseasTokenServiceTest {
|
||||
@MockBean private AuthAccountRepository accounts;
|
||||
@MockBean private AuthorizationLoader authorizationLoader;
|
||||
@MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder;
|
||||
@Resource private OverseasTokenService service;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class Config {
|
||||
@Bean OverseasJwtProperties properties() {
|
||||
return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid",
|
||||
Duration.ofMinutes(15));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signsShortRs256TokenWithCrossRegionClaims() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L);
|
||||
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
|
||||
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(accounts.findById(42L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
when(encoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("overseas-token");
|
||||
|
||||
var response = service.exchange(42L);
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(encoder).encode(captor.capture());
|
||||
var parameters = captor.getValue();
|
||||
assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
|
||||
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
|
||||
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
|
||||
assertThat(parameters.getClaims().getClaims())
|
||||
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
|
||||
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
|
||||
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
|
||||
.containsEntry("authorities", Set.of("merchant:order:list"))
|
||||
.doesNotContainKeys("user_id", "tenant_id", "app_code");
|
||||
assertThat(response.expiresIn()).isEqualTo(900);
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer {
|
||||
if (payload == null || payload.isNull()) {
|
||||
throw new IllegalArgumentException("事件负载不能为空");
|
||||
}
|
||||
return write(sort(payload));
|
||||
}
|
||||
|
||||
/** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */
|
||||
public static byte[] canonicalizeLegacy(JsonNode payload) {
|
||||
if (payload == null || payload.isNull()) {
|
||||
throw new IllegalArgumentException("事件负载不能为空");
|
||||
}
|
||||
return write(sortLegacy(payload));
|
||||
}
|
||||
|
||||
private static byte[] write(JsonNode payload) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsBytes(sort(payload));
|
||||
return OBJECT_MAPPER.writeValueAsBytes(payload);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalArgumentException("事件负载无法规范化", exception);
|
||||
}
|
||||
@@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer {
|
||||
node.forEach(child -> result.add(sort(child)));
|
||||
return result;
|
||||
}
|
||||
if (node.isNumber()) {
|
||||
return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros());
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private static JsonNode sortLegacy(JsonNode node) {
|
||||
if (node.isObject()) {
|
||||
var names = new java.util.ArrayList<String>();
|
||||
node.fieldNames().forEachRemaining(names::add);
|
||||
names.sort(String::compareTo);
|
||||
var result = OBJECT_MAPPER.createObjectNode();
|
||||
names.forEach(name -> result.set(name, sortLegacy(node.get(name))));
|
||||
return result;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
var result = OBJECT_MAPPER.createArrayNode();
|
||||
node.forEach(child -> result.add(sortLegacy(child)));
|
||||
return result;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ public final class EventTypes {
|
||||
public static final String ORDER_APPROVED = "OrderApproved";
|
||||
public static final String ORDER_CANCELLED = "OrderCancelled";
|
||||
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
||||
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
|
||||
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
||||
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
|
||||
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
|
||||
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 头程装柜完成后交给海外尾程的不可变 V1 快照。 */
|
||||
public record FirstLegLoadingPlanCompletedV1(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
List<Order> orders) {
|
||||
|
||||
public record Order(
|
||||
String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
|
||||
}
|
||||
}
|
||||
|
||||
public record Item(
|
||||
String sourceInboundDetailId, String skuCode,
|
||||
String productNameZh, String productNameEn,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaTrackingNo,
|
||||
String shipmentNo, String shippingMark, String palletNo, List<String> cartonMarks) {
|
||||
public Item {
|
||||
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
|
||||
}
|
||||
public Item(String sourceInboundDetailId, String skuCode,
|
||||
String productNameZh, String productNameEn,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaTrackingNo,
|
||||
String shipmentNo, String shippingMark) {
|
||||
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
|
||||
productQuantity, palletCount, grossWeightKg, netWeightKg,
|
||||
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
|
||||
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
|
||||
shipmentNo, shippingMark, null, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
/** 一键代发入仓单的托盘冻结快照。 */
|
||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||
|
||||
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
|
||||
public record SkuPackageSnapshot(String skuCode, String productName,
|
||||
int packageCount, int productQuantity,
|
||||
List<String> cartonMarks) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
|
||||
public final class IamSecurityEventsV1 {
|
||||
public static final int VERSION = 1;
|
||||
|
||||
private IamSecurityEventsV1() { }
|
||||
|
||||
public record UserStatusChanged(long authUserId, long tenantId, String appCode,
|
||||
String status, long permissionVersion, Instant changedAt) { }
|
||||
|
||||
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
|
||||
|
||||
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
|
||||
long permissionVersion, String reason, Instant changedAt) { }
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
class EventPayloadCanonicalizerTest {
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception {
|
||||
var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}");
|
||||
var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}");
|
||||
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized));
|
||||
assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)),
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception {
|
||||
var negativeZero = json.readTree("{\"value\":-0.000}");
|
||||
var zero = json.readTree("{\"value\":0}");
|
||||
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero));
|
||||
assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)),
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero)));
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FirstLegLoadingPlanCompletedV1Test {
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void v1PayloadRoundTripsWithoutLosingLargeIds() throws Exception {
|
||||
var item = new FirstLegLoadingPlanCompletedV1.Item("2088000000000000001", "SKU-1", "产品", "Product",
|
||||
2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), new BigDecimal("12.000"),
|
||||
new BigDecimal("0.200"), "2088000000000000002", "ONT8", "ONT8", "FBA-1", "SHIP-1", "MARK");
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
||||
new BigDecimal("0.200"), List.of(item));
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
|
||||
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception {
|
||||
var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
|
||||
"SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002"));
|
||||
var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
|
||||
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
||||
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
|
||||
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||
"3", "海外仓", List.of(order));
|
||||
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||
|
||||
assertThat(restored).isEqualTo(payload);
|
||||
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
||||
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
|
||||
*/
|
||||
@AutoConfiguration(
|
||||
after = HmacJwtDecoderAutoConfiguration.class,
|
||||
after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class},
|
||||
before = SecurityAutoConfiguration.class)
|
||||
@EnableMethodSecurity
|
||||
public class BaiyueSecurityAutoConfiguration {
|
||||
@@ -47,11 +47,18 @@ public class BaiyueSecurityAutoConfiguration {
|
||||
@ConditionalOnMissingBean
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint entryPoint,
|
||||
StringRedisTemplate redisTemplate,
|
||||
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint,
|
||||
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix, objectMapper));
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
|
||||
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
|
||||
AccessTokenSessionValidator validator;
|
||||
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
|
||||
else {
|
||||
StringRedisTemplate template=redis.getIfAvailable();
|
||||
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
|
||||
validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper);
|
||||
}
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */
|
||||
public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||
@Override public AccessTokenSession validate(Jwt jwt) {
|
||||
Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id")));
|
||||
return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")),
|
||||
values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions"))));
|
||||
}
|
||||
private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} }
|
||||
private static Set<String> values(Object value){ Set<String> result=new LinkedHashSet<>(); if(value instanceof Iterable<?> it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; }
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets;
|
||||
* HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。
|
||||
*/
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name = "baiyue.security.jwt-secret")
|
||||
@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true)
|
||||
public class HmacJwtDecoderAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(JwtDecoder.class)
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks")
|
||||
public class JwksJwtDecoderAutoConfiguration {
|
||||
@Bean @ConditionalOnMissingBean(JwtDecoder.class)
|
||||
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri,
|
||||
@Value("${baiyue.security.issuer:auth-service}") String issuer,
|
||||
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
|
||||
.jwsAlgorithm(SignatureAlgorithm.RS256).build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
JwtValidators.createDefaultWithIssuer(issuer),
|
||||
new JwtClaimValidator<List<String>>("aud", values -> values != null && values.contains(audience))));
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
@@ -23,20 +23,21 @@ public class JwtClaimsMapper {
|
||||
addRoles(permissions, session.roles());
|
||||
}
|
||||
|
||||
String appCode = jwt.getClaimAsString("app_code");
|
||||
String appCode = firstNonBlank(jwt.getClaimAsString("appCode"), jwt.getClaimAsString("app_code"));
|
||||
if (appCode != null && !appCode.isBlank()) {
|
||||
permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
String username = firstNonBlank(jwt.getClaimAsString("username"),
|
||||
jwt.getClaimAsString("preferred_username"), jwt.getSubject());
|
||||
Long userId = longClaim(jwt, "user_id");
|
||||
Long userId = longClaim(jwt, "userId");
|
||||
if (userId == null) userId = longClaim(jwt, "user_id");
|
||||
AuthenticatedUser user = new AuthenticatedUser(
|
||||
userId == null ? longValue(jwt.getSubject()) : userId,
|
||||
longClaim(jwt, "tenant_id"),
|
||||
longClaimEither(jwt, "tenantId", "tenant_id"),
|
||||
longClaim(jwt, "operator_tenant_id"),
|
||||
longClaim(jwt, "membership_id"),
|
||||
username,
|
||||
jwt.getClaimAsString("app_code"),
|
||||
appCode,
|
||||
jwt.getClaimAsString("client_type"),
|
||||
permissions,
|
||||
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
|
||||
@@ -76,6 +77,10 @@ public class JwtClaimsMapper {
|
||||
if (value instanceof String text) return longValue(text);
|
||||
return null;
|
||||
}
|
||||
private Long longClaimEither(Jwt jwt, String first, String second) {
|
||||
Long value = longClaim(jwt, first);
|
||||
return value == null ? longClaim(jwt, second) : value;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) if (value != null && !value.isBlank()) return value;
|
||||
|
||||
+1
@@ -1,2 +1,3 @@
|
||||
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
|
||||
|
||||
@@ -6,5 +6,12 @@ spring:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue:
|
||||
security:
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:2h}
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
|
||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||
overseas-jwt:
|
||||
enabled: ${OVERSEAS_JWT_ENABLED:true}
|
||||
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||
|
||||
@@ -19,6 +19,7 @@ x-common: &common
|
||||
REDIS_DATABASE: ${REDIS_DATABASE:-0}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
|
||||
BAIYUE_AUDIT_ENDPOINT: ${BAIYUE_AUDIT_ENDPOINT:-http://audit-service:9700/internal/audit/events}
|
||||
SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}
|
||||
AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false}
|
||||
AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false}
|
||||
|
||||
@@ -4,36 +4,54 @@
|
||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>aliyun-public</id>
|
||||
<name>阿里云 Maven 公共仓库</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
<id>baiyue-nexus-public</id>
|
||||
<name>柏悦 Nexus Maven 聚合仓库</name>
|
||||
<url>http://8.163.30.128:18081/repository/maven-public/</url>
|
||||
<mirrorOf>external:*</mirrorOf>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
|
||||
<servers>
|
||||
<server>
|
||||
<id>baiyue-nexus-public</id>
|
||||
<username>${env.NEXUS_USERNAME}</username>
|
||||
<password>${env.NEXUS_PASSWORD}</password>
|
||||
</server>
|
||||
<server>
|
||||
<id>baiyue-nexus-releases</id>
|
||||
<username>${env.NEXUS_USERNAME}</username>
|
||||
<password>${env.NEXUS_PASSWORD}</password>
|
||||
</server>
|
||||
<server>
|
||||
<id>baiyue-nexus-snapshots</id>
|
||||
<username>${env.NEXUS_USERNAME}</username>
|
||||
<password>${env.NEXUS_PASSWORD}</password>
|
||||
</server>
|
||||
</servers>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>国内仓库</id>
|
||||
<id>柏悦 Nexus</id>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
<id>baiyue-nexus-public</id>
|
||||
<url>http://8.163.30.128:18081/repository/maven-public/</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
<snapshots><enabled>true</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>central</id>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
<id>baiyue-nexus-public</id>
|
||||
<url>http://8.163.30.128:18081/repository/maven-public/</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
<snapshots><enabled>true</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<activeProfiles>
|
||||
<activeProfile>国内仓库</activeProfile>
|
||||
<activeProfile>柏悦 Nexus</activeProfile>
|
||||
</activeProfiles>
|
||||
</settings>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
|
||||
- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
|
||||
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
|
||||
- IAM 跨区安全事件基线已内联到固定入口,包括主体权限版本、客户状态版本和可靠事件发送箱 3 张表;模块溯源脚本为 `iam/src/main/resources/db/security-event-outbox.sql`。IAM 新版本启动前必须先执行固定入口,避免安全事件 Relay 和授权快照查询因缺表失败。
|
||||
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
|
||||
|
||||
后续追加增量必须满足:
|
||||
|
||||
+605
-71
@@ -1494,7 +1494,7 @@ VALUES
|
||||
'/downloads', 40, 'ACTIVE', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no),
|
||||
status = 'ACTIVE', built_in = 1, deleted = 0;
|
||||
status = VALUES(status), built_in = 1, deleted = VALUES(deleted);
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
|
||||
@@ -2401,7 +2401,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
|
||||
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 '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜',
|
||||
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、PREPARING草稿预入库、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜',
|
||||
order_status_snapshot VARCHAR(24) NOT NULL 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已作废',
|
||||
@@ -2657,6 +2657,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '内部兼容批次字段,固定为1且不进入箱码',
|
||||
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
||||
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
||||
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
||||
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
|
||||
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
|
||||
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
|
||||
@@ -2664,11 +2668,67 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index),
|
||||
KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version),
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
|
||||
-- 存量环境可能缺少稳定箱码映射兼容字段;CREATE TABLE IF NOT EXISTS 不会补列。
|
||||
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE unified_carton_label_add_column(IN p_column VARCHAR(64), IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_inbound_carton_label'
|
||||
AND column_name=p_column) THEN
|
||||
SET @ddl = CONCAT('ALTER TABLE fl_inbound_carton_label ADD COLUMN `', p_column, '` ', p_definition);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
|
||||
CALL unified_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
|
||||
CALL unified_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
|
||||
CALL unified_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
|
||||
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
|
||||
|
||||
UPDATE fl_inbound_carton_label
|
||||
SET label_version=1,
|
||||
batch_status='ACTIVE'
|
||||
WHERE label_version IS NULL OR batch_status IS NULL;
|
||||
|
||||
DELETE FROM fl_inbound_carton_label WHERE batch_status='VOID';
|
||||
|
||||
UPDATE fl_inbound_carton_label
|
||||
SET label_version=1,
|
||||
box_code=CONCAT(inbound_code, '#', total_carton_count, '-', carton_index)
|
||||
WHERE batch_status='ACTIVE';
|
||||
|
||||
SET @ddl = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_inbound_carton_label'
|
||||
AND index_name='uk_carton_label_index'),
|
||||
'ALTER TABLE fl_inbound_carton_label DROP INDEX uk_carton_label_index', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(
|
||||
NOT EXISTS(SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_inbound_carton_label'
|
||||
AND index_name='uk_carton_label_index'),
|
||||
'ALTER TABLE fl_inbound_carton_label ADD UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(
|
||||
NOT EXISTS(SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_inbound_carton_label'
|
||||
AND index_name='idx_carton_label_active'),
|
||||
'ALTER TABLE fl_inbound_carton_label ADD KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
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和数据隔离键',
|
||||
@@ -4346,7 +4406,7 @@ BEGIN
|
||||
'platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create',
|
||||
'platform:overseas-storage-fee:edit', 'platform:overseas-storage-fee:delete')) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用权限未完整创建';
|
||||
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用列表/编辑权限未完整创建';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
@@ -4395,6 +4455,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
|
||||
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
||||
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
|
||||
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空',
|
||||
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空',
|
||||
business_date DATE NOT NULL COMMENT '业务发生日',
|
||||
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
|
||||
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
|
||||
@@ -8686,7 +8748,7 @@ BEGIN
|
||||
WHERE (id BETWEEN 5102140 AND 5102149
|
||||
AND permission_code <> CASE id
|
||||
WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list'
|
||||
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:view'
|
||||
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list'
|
||||
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
|
||||
WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit'
|
||||
WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit'
|
||||
@@ -8712,7 +8774,6 @@ INSERT INTO iam_permission
|
||||
route_path,http_method,sort_no,status,built_in,deleted)
|
||||
VALUES
|
||||
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
|
||||
(5102141,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:view','查看订舱',NULL,'GET',10,'ACTIVE',1,0),
|
||||
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0),
|
||||
(5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0),
|
||||
@@ -8807,7 +8868,7 @@ BEGIN
|
||||
WHERE (id BETWEEN 5102150 AND 5102154
|
||||
AND permission_code <> CASE id
|
||||
WHEN 5102150 THEN 'platform:firstleg:truck-appointment:list'
|
||||
WHEN 5102151 THEN 'platform:firstleg:truck-appointment:view'
|
||||
WHEN 5102151 THEN 'platform:firstleg:truck-appointment:list'
|
||||
WHEN 5102152 THEN 'platform:firstleg:truck-appointment:create'
|
||||
WHEN 5102153 THEN 'platform:firstleg:truck-appointment:edit'
|
||||
WHEN 5102154 THEN 'platform:firstleg:truck-appointment:operate'
|
||||
@@ -8828,7 +8889,6 @@ INSERT INTO iam_permission
|
||||
route_path,http_method,sort_no,status,built_in,deleted)
|
||||
VALUES
|
||||
(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list','拖车预约','/first-leg/truck-appointments','GET',90,'ACTIVE',1,0),
|
||||
(5102151,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:view','查看拖车预约',NULL,'GET',10,'ACTIVE',1,0),
|
||||
(5102152,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:create','创建拖车预约',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102153,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:edit','编辑拖车预约',NULL,'PUT',30,'ACTIVE',1,0),
|
||||
(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate','操作拖车预约',NULL,'POST',40,'ACTIVE',1,0)
|
||||
@@ -8859,10 +8919,10 @@ BEGIN
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102140 AND 5102149
|
||||
AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 10
|
||||
AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 9
|
||||
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102150 AND 5102154
|
||||
AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 5 THEN
|
||||
AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约权限未完整写入';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))
|
||||
@@ -8870,13 +8930,13 @@ BEGIN
|
||||
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 20
|
||||
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 18
|
||||
OR (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))
|
||||
FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND p.permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 10 THEN
|
||||
AND p.permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -10202,6 +10262,13 @@ CREATE TABLE IF NOT EXISTS fin_subject (
|
||||
UNIQUE KEY uk_fin_subject_code (subject_code)
|
||||
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
|
||||
|
||||
INSERT INTO fin_subject
|
||||
(subject_code, subject_name, status, version, created_by, updated_by)
|
||||
VALUES
|
||||
('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
@@ -10438,6 +10505,80 @@ CREATE TABLE IF NOT EXISTS fin_audit_log (
|
||||
KEY idx_fin_audit_operator (operator_id, created_at)
|
||||
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_settlement (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
settlement_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一结算单号',
|
||||
direction VARCHAR(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付',
|
||||
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '结算生成时客户名称快照',
|
||||
supplier_id BIGINT UNSIGNED NULL COMMENT '应付结算供应商ID;应收结算为空',
|
||||
supplier_name_snapshot VARCHAR(200) NULL COMMENT '应付结算供应商名称快照',
|
||||
reconciliation_statement_id BIGINT UNSIGNED NULL COMMENT '应收结算关联的已出账客户对账单ID',
|
||||
business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
|
||||
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数',
|
||||
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数',
|
||||
generated_date DATE NOT NULL COMMENT '结算单生成日期',
|
||||
due_date DATE NOT NULL COMMENT '结算到期日期',
|
||||
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
|
||||
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_settlement_no (tenant_id, settlement_no),
|
||||
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
|
||||
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
|
||||
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
|
||||
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
|
||||
CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount)
|
||||
) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_settlement_line (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
|
||||
fee_source_id BIGINT UNSIGNED NOT NULL COMMENT '结算固化的Finance费用来源ID',
|
||||
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_settlement_line_source (settlement_id, fee_source_id),
|
||||
KEY idx_fin_settlement_line_customer (tenant_id, settlement_id),
|
||||
KEY idx_fin_settlement_line_source (fee_source_id),
|
||||
CONSTRAINT chk_fin_settlement_line_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_settlement_line_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_settlement_line_amount CHECK (amount > 0)
|
||||
) ENGINE=InnoDB COMMENT='正式结算单费用来源金额快照明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_settlement_payment (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
|
||||
transaction_type VARCHAR(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款',
|
||||
transaction_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收付款业务号',
|
||||
transaction_date DATE NOT NULL COMMENT '实际收款或付款日期',
|
||||
amount DECIMAL(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
method VARCHAR(32) NOT NULL COMMENT '人工登记的收付款方式',
|
||||
reference_no VARCHAR(128) NULL COMMENT '银行流水号或外部参考号',
|
||||
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
|
||||
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
|
||||
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_settlement_payment_no (tenant_id, transaction_no),
|
||||
KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at),
|
||||
CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
|
||||
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
|
||||
|
||||
USE baiyue_firstleg;
|
||||
-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql
|
||||
-- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行)
|
||||
@@ -10545,6 +10686,10 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_id',
|
||||
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_name',
|
||||
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id',
|
||||
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name',
|
||||
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
|
||||
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
|
||||
|
||||
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
|
||||
@@ -10566,6 +10711,11 @@ BEGIN
|
||||
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用往来单位快照字段不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
|
||||
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用往来单位快照字段不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL firstleg_finance_assert_schema();
|
||||
@@ -11965,6 +12115,8 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_c
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_id', '入库单ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbounded_at', '入库完成时间快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'invoice_title', '发票抬头快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_id', '应付往来单位ID快照;应收为空');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_name', '应付往来单位名称快照;应收为空');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'package_count', '件数快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'publication_status', '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'published_at', '发布时间');
|
||||
@@ -12164,7 +12316,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'package_count'
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'pallet_count', '已分配总托盘数快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'parent_inbound_id', '直属主入仓单ID;主单为空');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode', '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'process_status', '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'process_status', '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'public_remark', '客户可见备注');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'root_inbound_id', '根主入仓单ID;存量主单可为空,按自身ID解释');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'split_purpose', '履约分片用途:MASTER、LOADED、REMAINDER');
|
||||
@@ -13571,11 +13723,19 @@ BEGIN
|
||||
WHERE table_schema = 'baiyue_finance'
|
||||
AND table_name IN ('fin_fee_source', 'fin_inbox_event', 'fin_subject',
|
||||
'fin_customer_settlement_profile', 'fin_bill', 'fin_bill_line',
|
||||
'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation',
|
||||
'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log')) <> 13 THEN
|
||||
'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation',
|
||||
'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log',
|
||||
'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:Finance 首期人工财务表未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema='baiyue_finance' AND table_name='fin_settlement'
|
||||
AND index_name='idx_fin_settlement_customer_direction_status'
|
||||
AND column_name IN ('tenant_id','direction','status')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:Finance 客户结算汇总索引未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_finance'
|
||||
AND ((table_name = 'fin_customer_settlement_profile' AND column_name IN ('tenant_id', 'credit_days'))
|
||||
@@ -13605,6 +13765,21 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:稳定箱号映射表未创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_inbound_carton_label'
|
||||
AND column_name IN ('label_version', 'batch_status', 'cargo_fingerprint', 'voided_at')) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:稳定箱码映射兼容字段未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_inbound_carton_label'
|
||||
AND index_name = 'idx_carton_label_active'
|
||||
AND non_unique = 1) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:箱唛有效批次索引未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_inbound_measurement_item'
|
||||
@@ -13891,7 +14066,6 @@ INSERT INTO baiyue_iam.iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
|
||||
VALUES
|
||||
(5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', '财务管理', '/finance', NULL, 60, 'ACTIVE', 0),
|
||||
(5105010, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:view', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0),
|
||||
(5105011, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:create', '创建账单', NULL, 'POST', 20, 'ACTIVE', 0),
|
||||
(5105012, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:update', '修改草稿账单', NULL, 'PUT', 30, 'ACTIVE', 0),
|
||||
(5105013, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:issue', '出账', NULL, 'POST', 40, 'ACTIVE', 0),
|
||||
@@ -13916,9 +14090,7 @@ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM baiyue_iam.iam_permission
|
||||
WHERE (id = 5105022 AND permission_code <> 'platform:finance:account:view')
|
||||
OR (id = 5105023 AND permission_code <> 'platform:finance:fee-source:view')
|
||||
OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
|
||||
WHERE (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
|
||||
OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement')
|
||||
OR (id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create')
|
||||
OR (id = 5105027 AND permission_code <> 'platform:finance:reconciliation:review')
|
||||
@@ -13927,11 +14099,8 @@ BEGIN
|
||||
OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void')
|
||||
OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create')
|
||||
OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create')
|
||||
OR (id = 5105033 AND permission_code <> 'platform:finance:settlement-ar:view')
|
||||
OR (id = 5105034 AND permission_code <> 'platform:finance:settlement-ap:view')
|
||||
OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create')
|
||||
OR (permission_code = 'platform:finance:account:view' AND id <> 5105022)
|
||||
OR (permission_code = 'platform:finance:fee-source:view' AND id <> 5105023)
|
||||
OR (id = 5105036 AND permission_code <> 'platform:finance:settlement:void')
|
||||
OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
|
||||
OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025)
|
||||
OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026)
|
||||
@@ -13941,10 +14110,9 @@ BEGIN
|
||||
OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030)
|
||||
OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031)
|
||||
OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032)
|
||||
OR (permission_code = 'platform:finance:settlement-ar:view' AND id <> 5105033)
|
||||
OR (permission_code = 'platform:finance:settlement-ap:view' AND id <> 5105034)
|
||||
OR (permission_code = 'platform:finance:payment:create' AND id <> 5105035)
|
||||
OR (id BETWEEN 5105022 AND 5105035
|
||||
OR (permission_code = 'platform:finance:settlement:void' AND id <> 5105036)
|
||||
OR (id BETWEEN 5105022 AND 5105036
|
||||
AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
@@ -13956,9 +14124,9 @@ BEGIN
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5105022, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:account:view',
|
||||
'查看客户财务账户及流水', NULL, 'GET', 130, 'ACTIVE', 1, 0),
|
||||
'查看客户财务账户及流水(已停用)', NULL, 'GET', 130, 'INACTIVE', 1, 1),
|
||||
(5105023, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:view',
|
||||
'查看财务费用来源', NULL, 'GET', 140, 'ACTIVE', 1, 0),
|
||||
'查看财务费用来源(已停用)', NULL, 'GET', 140, 'INACTIVE', 1, 1),
|
||||
(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review',
|
||||
'审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0),
|
||||
(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement',
|
||||
@@ -13970,14 +14138,13 @@ BEGIN
|
||||
(5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0),
|
||||
(5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0),
|
||||
(5105032, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:create', '生成应付结算单', NULL, 'POST', 230, 'ACTIVE', 1, 0),
|
||||
(5105033, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:view', '查看应收结算单', NULL, 'GET', 240, 'ACTIVE', 1, 0),
|
||||
(5105034, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:view', '查看应付结算单', NULL, 'GET', 250, 'ACTIVE', 1, 0),
|
||||
(5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 0)
|
||||
(5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 0),
|
||||
(5105036, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement:void', '作废或重开结算单', NULL, 'POST', 270, 'ACTIVE', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
|
||||
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
|
||||
http_method = VALUES(http_method), sort_no = VALUES(sort_no),
|
||||
status = 'ACTIVE', built_in = 1, deleted = 0;
|
||||
status = VALUES(status), built_in = 1, deleted = VALUES(deleted);
|
||||
|
||||
IF (SELECT COUNT(*)
|
||||
FROM baiyue_iam.iam_permission
|
||||
@@ -13989,8 +14156,6 @@ BEGIN
|
||||
AND built_in = 1
|
||||
AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'platform:finance:account:view',
|
||||
'platform:finance:fee-source:view',
|
||||
'platform:finance:fee-source:review',
|
||||
'platform:finance:fee-source:settlement',
|
||||
'platform:finance:reconciliation:create',
|
||||
@@ -14000,9 +14165,8 @@ BEGIN
|
||||
'platform:finance:reconciliation:void',
|
||||
'platform:finance:settlement-ar:create',
|
||||
'platform:finance:settlement-ap:create',
|
||||
'platform:finance:settlement-ar:view',
|
||||
'platform:finance:settlement-ap:view',
|
||||
'platform:finance:payment:create')) <> 14 THEN
|
||||
'platform:finance:payment:create',
|
||||
'platform:finance:settlement:void')) <> 11 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'finance G1-G4 permission catalog is incomplete';
|
||||
END IF;
|
||||
@@ -14011,11 +14175,19 @@ DELIMITER ;
|
||||
CALL migrate_finance_g1_g4_permissions();
|
||||
DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions;
|
||||
|
||||
DELETE rp FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||
AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
|
||||
UPDATE baiyue_iam.iam_permission SET status='INACTIVE',deleted=1
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM'
|
||||
AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
|
||||
|
||||
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
|
||||
'platform:finance',
|
||||
'platform:finance:bill:view', 'platform:finance:bill:create',
|
||||
'platform:finance:bill:create',
|
||||
'platform:finance:bill:update', 'platform:finance:bill:issue',
|
||||
'platform:finance:bill:adjust', 'platform:finance:bill:void',
|
||||
'platform:finance:receipt:view', 'platform:finance:receipt:create',
|
||||
@@ -14025,42 +14197,31 @@ ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
|
||||
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
|
||||
'platform:finance:account:view', 'platform:finance:fee-source:view',
|
||||
'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement',
|
||||
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
|
||||
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
|
||||
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
|
||||
'platform:finance:settlement-ap:create', 'platform:finance:settlement-ar:view',
|
||||
'platform:finance:settlement-ap:view', 'platform:finance:payment:create')
|
||||
'platform:finance:settlement-ap:create', 'platform:finance:payment:create',
|
||||
'platform:finance:settlement:void')
|
||||
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
|
||||
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%')
|
||||
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
|
||||
|
||||
-- Finance 权限和角色授权断言。
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_finance_permissions()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 26 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 27 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_finance_permissions();
|
||||
DROP PROCEDURE IF EXISTS assert_finance_permissions;
|
||||
-- 开发期 list-only 收口:先移除角色关联并停用旧只读资源,再执行最终 Finance 断言。
|
||||
DELETE rp FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||
AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view',
|
||||
'platform:finance:fee-source:view','platform:finance:settlement-ar:view',
|
||||
'platform:finance:settlement-ap:view');
|
||||
UPDATE baiyue_iam.iam_permission SET status='INACTIVE', deleted=1
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM'
|
||||
AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view',
|
||||
'platform:finance:fee-source:view','platform:finance:settlement-ar:view',
|
||||
'platform:finance:settlement-ap:view');
|
||||
|
||||
CALL assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_unified_final_schema;
|
||||
CALL assert_loading_pick_task_shapes(1);
|
||||
DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes;
|
||||
|
||||
@@ -14236,6 +14397,18 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_container_transport_history (
|
||||
PRIMARY KEY(id),KEY idx_container_transport_history(container_booking_id,created_at,id)
|
||||
) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计';
|
||||
|
||||
ALTER TABLE baiyue_firstleg.fl_container_transport_history
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '运输字段变更历史ID',
|
||||
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
|
||||
MODIFY COLUMN before_container_no VARCHAR(64) NULL COMMENT '变更前真实集装箱号',
|
||||
MODIFY COLUMN after_container_no VARCHAR(64) NULL COMMENT '变更后真实集装箱号',
|
||||
MODIFY COLUMN before_voyage_no VARCHAR(64) NULL COMMENT '变更前航次号',
|
||||
MODIFY COLUMN after_voyage_no VARCHAR(64) NULL COMMENT '变更后航次号',
|
||||
MODIFY COLUMN reason VARCHAR(500) NULL COMMENT '运输字段变更原因',
|
||||
MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '变更操作人ID',
|
||||
MODIFY COLUMN operator_name VARCHAR(64) NULL COMMENT '变更操作人名称快照',
|
||||
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '变更记录创建时间';
|
||||
|
||||
/* IAM 消息中心基线。来源:iam/src/main/resources/db/notification-center.sql */
|
||||
DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_permission_ids;
|
||||
DELIMITER $$
|
||||
@@ -14356,26 +14529,23 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM 消息中心唯一索引不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0
|
||||
AND (permission_code LIKE 'platform:notification%' OR permission_code='merchant:notification:view')) <> 11 THEN
|
||||
AND (permission_code LIKE 'platform:notification%' OR permission_code='merchant:notification:view')) <> 13 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM 消息中心权限不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code LIKE 'platform:notification%') <> 10 THEN
|
||||
WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code LIKE 'platform:notification%') <> 12 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员消息中心授权不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE r.role_code='MERCHANT_OWNER' AND p.permission_code='merchant:notification:view') <> 1 THEN
|
||||
WHERE r.role_code='MERCHANT_OWNER' AND p.permission_code IN ('merchant:notification:view','merchant:notification:read','merchant:notification:read-all')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:企业管理员消息查看授权不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL baiyue_iam.assert_notification_center_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_center_schema;
|
||||
|
||||
-- 货柜清关与装柜清单报关采用同一份轻量文件事实,不再要求独立报关 case 状态机。
|
||||
USE baiyue_firstleg;
|
||||
DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column;
|
||||
@@ -14428,6 +14598,370 @@ BEGIN
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。
|
||||
-- 二、三期入库完成后的唯一状态为 WAIT_SPLIT。
|
||||
USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '异常历史ID',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定货柜预订ID',
|
||||
action VARCHAR(16) NOT NULL COMMENT 'MARK标记异常/CANCEL取消异常',
|
||||
reason VARCHAR(500) NOT NULL COMMENT '标记或取消原因',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
|
||||
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_container_exception_operation (container_booking_id, operator_id, operation_id),
|
||||
KEY idx_container_exception_history (container_booking_id, occurred_at, id),
|
||||
CONSTRAINT fk_container_exception_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_container_exception_action CHECK (action IN ('MARK','CANCEL'))
|
||||
) ENGINE=InnoDB COMMENT='货柜异常标记及取消原因历史';
|
||||
DROP PROCEDURE IF EXISTS phase3_container_exception_add_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase3_container_exception_add_column(IN p_name VARCHAR(64),IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_exception_history' AND column_name=p_name) THEN
|
||||
SET @ddl=CONCAT('ALTER TABLE fl_container_exception_history ADD COLUMN ',p_name,' ',p_definition);
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_container_exception_add_column('operation_id',"VARCHAR(128) NULL COMMENT '同一操作人幂等键' AFTER operator_name");
|
||||
CALL phase3_container_exception_add_column('request_hash',"CHAR(64) NULL COMMENT '规范化请求摘要' AFTER operation_id");
|
||||
DROP PROCEDURE phase3_container_exception_add_column;
|
||||
UPDATE fl_container_exception_history SET operation_id=CONCAT('LEGACY-',id) WHERE operation_id IS NULL OR operation_id='';
|
||||
UPDATE fl_container_exception_history SET request_hash=SHA2(CONCAT(action,'|',container_booking_id,'|',id,'|',reason),256) WHERE request_hash IS NULL OR request_hash='';
|
||||
ALTER TABLE fl_container_exception_history
|
||||
MODIFY operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键',
|
||||
MODIFY request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要';
|
||||
SET @has_exception_operation_index=(SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_exception_history' AND index_name='uk_container_exception_operation');
|
||||
SET @ddl=IF(@has_exception_operation_index=0,'ALTER TABLE fl_container_exception_history ADD UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id)','SELECT 1');
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
DROP TRIGGER IF EXISTS trg_container_exception_history_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_container_exception_history_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_exception_history_before_update BEFORE UPDATE ON fl_container_exception_history FOR EACH ROW
|
||||
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可修改'; END$$
|
||||
CREATE TRIGGER trg_container_exception_history_before_delete BEFORE DELETE ON fl_container_exception_history FOR EACH ROW
|
||||
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可删除'; END$$
|
||||
DELIMITER ;
|
||||
|
||||
-- Admin 原型菜单与财务五页权限补充(来源:iam/src/main/resources/db/admin-prototype-menu-permissions.sql)。
|
||||
USE baiyue_iam;
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5105040,0,'PLATFORM','MENU',5105000,'platform:finance:customer-account:list','客户账户','/finance/customer-accounts','GET',10,'ACTIVE',1,0),
|
||||
(5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0),
|
||||
(5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0),
|
||||
(5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0),
|
||||
(5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
|
||||
UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete');
|
||||
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
|
||||
UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list');
|
||||
UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:settlement',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON';
|
||||
|
||||
INSERT INTO baiyue_iam.iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
|
||||
VALUES
|
||||
(5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name),
|
||||
route_path = VALUES(route_path), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
|
||||
|
||||
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT r.id, p.id, r.tenant_id
|
||||
FROM baiyue_iam.iam_role r
|
||||
JOIN baiyue_iam.iam_permission p ON p.tenant_id = r.tenant_id
|
||||
WHERE r.role_code = 'MERCHANT_OWNER' AND r.status = 'ACTIVE' AND r.deleted = 0
|
||||
AND p.permission_code IN ('merchant:order:quick-create', 'merchant:order:create')
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
ALTER TABLE baiyue_order.ord_order_cargo
|
||||
MODIFY COLUMN cargo_name VARCHAR(128) NULL COMMENT '货物名称;草稿可空,提交前必填',
|
||||
MODIFY COLUMN package_type VARCHAR(32) NULL COMMENT '包装类型;草稿可空,提交前必填',
|
||||
MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0',
|
||||
MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0';
|
||||
|
||||
-- 平台公共供应商业务档案及权限(来源:warehouse-supplier.sql / platform-supplier-permissions.sql)。
|
||||
USE baiyue_warehouse;
|
||||
CREATE TABLE IF NOT EXISTS wh_supplier (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
|
||||
name VARCHAR(160) NOT NULL COMMENT '供应商名称', category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证',
|
||||
scope VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL', region VARCHAR(80) NOT NULL COMMENT '地区',
|
||||
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', contact_name VARCHAR(80) NOT NULL COMMENT '联系人',
|
||||
phone VARCHAR(40) NOT NULL COMMENT '联系电话', email VARCHAR(160) NULL COMMENT '电子邮箱', address VARCHAR(255) NULL COMMENT '公司地址',
|
||||
business_license_no VARCHAR(64) NULL COMMENT '营业执照号', license_file_id VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、INACTIVE停用', settlement_period VARCHAR(32) NOT NULL COMMENT '结算周期', 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 NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_wh_supplier_name(tenant_id,name), KEY idx_wh_supplier_filter(tenant_id,category,status,region),
|
||||
CONSTRAINT chk_wh_supplier_category CHECK(category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
|
||||
CONSTRAINT chk_wh_supplier_scope CHECK((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)),
|
||||
CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')),
|
||||
CONSTRAINT chk_wh_supplier_settlement CHECK(settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案';
|
||||
DROP PROCEDURE IF EXISTS supplier_add_column_if_missing;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE supplier_add_column_if_missing(IN p_name VARCHAR(64),IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND column_name=p_name) THEN
|
||||
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD COLUMN ',p_name,' ',p_definition); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL supplier_add_column_if_missing('scope',"VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL' AFTER category");
|
||||
CALL supplier_add_column_if_missing('license_file_id',"VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no");
|
||||
CALL supplier_add_column_if_missing('created_by',"BIGINT UNSIGNED NULL COMMENT '创建人ID'");
|
||||
CALL supplier_add_column_if_missing('created_at',"DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'");
|
||||
CALL supplier_add_column_if_missing('updated_by',"BIGINT UNSIGNED NULL COMMENT '最后修改人ID'");
|
||||
CALL supplier_add_column_if_missing('updated_at',"DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间'");
|
||||
CALL supplier_add_column_if_missing('deleted',"TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'");
|
||||
CALL supplier_add_column_if_missing('version',"INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'");
|
||||
DROP PROCEDURE supplier_add_column_if_missing;
|
||||
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='uk_wh_supplier_name'),'SELECT 1','ALTER TABLE wh_supplier ADD UNIQUE KEY uk_wh_supplier_name(tenant_id,name)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
|
||||
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter(tenant_id,category,status,region)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
|
||||
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE supplier_add_check_if_missing(IN p_name VARCHAR(64),IN p_expression TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_name=p_name AND constraint_type='CHECK') THEN
|
||||
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD CONSTRAINT ',p_name,' CHECK(',p_expression,')'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_category',"category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')");
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_scope',"(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)");
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_status',"status IN ('ACTIVE','INACTIVE')");
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_settlement',"settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')");
|
||||
DROP PROCEDURE supplier_add_check_if_missing;
|
||||
|
||||
USE baiyue_iam;
|
||||
DROP PROCEDURE IF EXISTS guard_platform_supplier_permission_ids;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE guard_platform_supplier_permission_ids()
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM iam_permission WHERE id BETWEEN 5103080 AND 5103083 AND permission_code<>CASE id WHEN 5103080 THEN 'platform:supplier:list' WHEN 5103081 THEN 'platform:supplier:create' WHEN 5103082 THEN 'platform:supplier:edit' WHEN 5103083 THEN 'platform:supplier:status' END)
|
||||
OR EXISTS(SELECT 1 FROM iam_permission WHERE permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status') AND id NOT BETWEEN 5103080 AND 5103083) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限固定ID或权限码已被其他语义占用';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL guard_platform_supplier_permission_ids(); DROP PROCEDURE guard_platform_supplier_permission_ids;
|
||||
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) VALUES
|
||||
(5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list','供应商列表','/suppliers','GET',80,'ACTIVE',1,0),
|
||||
(5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create','新增供应商',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit','编辑供应商',NULL,'PUT',20,'ACTIVE',1,0),
|
||||
(5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status','启停供应商',NULL,'PATCH',30,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type),parent_id=VALUES(parent_id),permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
|
||||
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
|
||||
AND p.permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status');
|
||||
DROP PROCEDURE IF EXISTS assert_platform_supplier_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_platform_supplier_permissions()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_supplier WHERE tenant_id<>0)<>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商存在非平台作用域数据'; END IF;
|
||||
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter'))<>3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表索引不完整'; END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 AND permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限资源初始化不完整'; END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PLATFORM_ADMIN供应商权限授权不完整'; END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_platform_supplier_permissions(); DROP PROCEDURE assert_platform_supplier_permissions;
|
||||
|
||||
-- 国内仓整柜入库、出库、库存和不可变交易流水。
|
||||
USE baiyue_warehouse;
|
||||
CREATE TABLE IF NOT EXISTS wh_domestic_container_order (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '整柜订单ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', business_type VARCHAR(16) NOT NULL COMMENT '业务类型:INBOUND入库、OUTBOUND出库',
|
||||
order_no VARCHAR(40) NOT NULL COMMENT '业务单号', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '平台国内仓ID', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称',
|
||||
container_no VARCHAR(32) NOT NULL COMMENT '柜号', cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称', box_count INT UNSIGNED NOT NULL COMMENT '箱数',
|
||||
volume_cbm DECIMAL(16,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', operation_time DATETIME(3) NOT NULL COMMENT '操作时间', status VARCHAR(16) NOT NULL COMMENT '状态:PENDING待处理、COMPLETED已完成',
|
||||
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 '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_dc_order_no(tenant_id,order_no,deleted),
|
||||
KEY idx_dc_order_list(tenant_id,business_type,warehouse_id,status,operation_time),
|
||||
KEY idx_dc_order_container(tenant_id,warehouse_id,container_no,business_type),
|
||||
CONSTRAINT chk_dc_order_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_order_type CHECK(business_type IN('INBOUND','OUTBOUND')),
|
||||
CONSTRAINT chk_dc_order_status CHECK(status IN('PENDING','COMPLETED')), CONSTRAINT chk_dc_order_quantity CHECK(box_count>0 AND volume_cbm>=0)
|
||||
) ENGINE=InnoDB COMMENT='国内仓独立整柜入出库单';
|
||||
ALTER TABLE wh_domestic_container_order
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '整柜订单ID';
|
||||
CREATE TABLE IF NOT EXISTS wh_domestic_container_inventory (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '库存ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '平台国内仓ID',
|
||||
warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', container_no VARCHAR(32) NOT NULL COMMENT '柜号', cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称',
|
||||
inbound_boxes INT UNSIGNED NOT NULL COMMENT '入库箱数', outbound_boxes INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '出库箱数', available_boxes INT UNSIGNED NOT NULL COMMENT '可用箱数',
|
||||
inbound_volume_cbm DECIMAL(16,4) NOT NULL COMMENT '入库体积(立方米)', inventory_status VARCHAR(24) NOT NULL COMMENT '库存状态:IN_STOCK在库、PARTIALLY_OUTBOUND部分出库、OUTBOUND已出库',
|
||||
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 '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_dc_inventory_container(tenant_id,warehouse_id,container_no,deleted),
|
||||
KEY idx_dc_inventory_list(tenant_id,warehouse_id,inventory_status,updated_at),
|
||||
CONSTRAINT chk_dc_inventory_scope CHECK(tenant_id=0),
|
||||
CONSTRAINT chk_dc_inventory_balance CHECK(inbound_boxes=outbound_boxes+available_boxes),
|
||||
CONSTRAINT chk_dc_inventory_status CHECK(inventory_status IN('IN_STOCK','PARTIALLY_OUTBOUND','OUTBOUND'))
|
||||
) ENGINE=InnoDB COMMENT='平台国内仓整柜库存台账';
|
||||
CREATE TABLE IF NOT EXISTS wh_domestic_container_transaction (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '流水ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', inventory_id BIGINT UNSIGNED NOT NULL COMMENT '库存台账ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '平台国内仓ID', container_no VARCHAR(32) NOT NULL COMMENT '柜号', business_type VARCHAR(16) NOT NULL COMMENT '业务类型:INBOUND入库、OUTBOUND出库',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '业务单ID', order_no VARCHAR(40) NOT NULL COMMENT '业务单号', quantity INT UNSIGNED NOT NULL COMMENT '变动箱数',
|
||||
operation_time DATETIME(3) NOT NULL COMMENT '操作时间', 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 '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_dc_transaction_order(tenant_id,business_type,order_id),
|
||||
KEY idx_dc_transaction_detail(tenant_id,inventory_id,operation_time),
|
||||
CONSTRAINT chk_dc_transaction_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_transaction_type CHECK(business_type IN('INBOUND','OUTBOUND')),
|
||||
CONSTRAINT chk_dc_transaction_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB COMMENT='国内仓整柜不可变进销存交易流水';
|
||||
CREATE TABLE IF NOT EXISTS wh_domestic_container_idempotency (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', operation_type VARCHAR(16) NOT NULL COMMENT '操作类型:INBOUND入库、OUTBOUND出库',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求哈希', resource_id BIGINT UNSIGNED NULL COMMENT '业务资源ID',
|
||||
resource_no VARCHAR(40) NULL COMMENT '业务资源编号', operation_status VARCHAR(16) NOT NULL COMMENT '状态:PROCESSING处理中、COMPLETED已完成',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_dc_idempotency(tenant_id,operation_type,idempotency_key),
|
||||
CONSTRAINT chk_dc_idempotency_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_idempotency_type CHECK(operation_type IN('INBOUND','OUTBOUND')),
|
||||
CONSTRAINT chk_dc_idempotency_status CHECK(operation_status IN('PROCESSING','COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='国内仓整柜写操作幂等记录';
|
||||
|
||||
USE baiyue_iam;
|
||||
DROP PROCEDURE IF EXISTS migrate_domestic_container_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_domestic_container_permissions()
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM iam_permission WHERE (id=5103070 AND permission_code<>'platform:domestic-container:list') OR (id=5103071 AND permission_code<>'platform:domestic-container:inbound:create') OR (id=5103072 AND permission_code<>'platform:domestic-container:outbound:create') OR (permission_code='platform:domestic-container:list' AND id<>5103070) OR (permission_code='platform:domestic-container:inbound:create' AND id<>5103071) OR (permission_code='platform:domestic-container:outbound:create' AND id<>5103072)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container permission id/code conflict';
|
||||
END IF;
|
||||
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) VALUES
|
||||
(5103070,0,'PLATFORM','MENU',5103000,'platform:domestic-container:list','整柜业务','/domestic-containers','GET',70,'ACTIVE',1,0),
|
||||
(5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create','新建整柜入库单','/api/platform/v1/domestic-containers/inbounds','POST',10,'ACTIVE',1,0),
|
||||
(5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create','新建整柜出库单','/api/platform/v1/domestic-containers/outbounds','POST',20,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type),parent_id=VALUES(parent_id),permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
|
||||
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
|
||||
AND p.permission_code IN('platform:domestic-container:list','platform:domestic-container:inbound:create','platform:domestic-container:outbound:create');
|
||||
IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 AND permission_code IN('platform:domestic-container:list','platform:domestic-container:inbound:create','platform:domestic-container:outbound:create'))<>3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container permission catalog is incomplete'; END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name IN('wh_domestic_container_order','wh_domestic_container_inventory','wh_domestic_container_transaction','wh_domestic_container_idempotency'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container tables are incomplete'; END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL migrate_domestic_container_permissions(); DROP PROCEDURE migrate_domestic_container_permissions;
|
||||
|
||||
-- 消息页面菜单与已读操作权限分离。
|
||||
USE baiyue_iam;
|
||||
UPDATE iam_permission SET permission_type='MENU',parent_id=5106000,permission_name='模板管理',route_path='/notifications/templates',http_method='GET',status='ACTIVE',deleted=0 WHERE id=5106003 AND permission_code='platform:notification:template:list';
|
||||
UPDATE iam_permission SET permission_type='MENU',parent_id=5106000,permission_name='消息管理',route_path='/notifications/messages',http_method='GET',status='ACTIVE',deleted=0 WHERE id=5106007 AND permission_code='platform:notification:task:list';
|
||||
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) VALUES
|
||||
(5106011,0,'PLATFORM','BUTTON',5106001,'platform:notification:read','标记消息已读','/api/platform/v1/notifications/{id}/read','PUT',610,'ACTIVE',1,0),
|
||||
(5106012,0,'PLATFORM','BUTTON',5106001,'platform:notification:read-all','全部标记已读','/api/platform/v1/notifications/read-all','PUT',611,'ACTIVE',1,0),
|
||||
(5106013,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read','标记消息已读','/api/merchant/v1/notifications/{id}/read','PUT',901,'ACTIVE',1,0),
|
||||
(5106014,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read-all','全部标记已读','/api/merchant/v1/notifications/read-all','PUT',902,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type),parent_id=VALUES(parent_id),permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
|
||||
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0
|
||||
WHERE r.tenant_id=0 AND r.status='ACTIVE' AND r.deleted=0
|
||||
AND ((r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:notification:read','platform:notification:read-all'))
|
||||
OR (r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND p.permission_code IN('merchant:notification:read','merchant:notification:read-all')));
|
||||
|
||||
CALL baiyue_iam.assert_notification_center_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_center_schema;
|
||||
|
||||
-- 开发期不保留独立详情权限:列表权限是唯一只读权限。
|
||||
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
|
||||
WHERE p.permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:supplier:view');
|
||||
UPDATE iam_permission SET status='INACTIVE',deleted=1
|
||||
WHERE permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:supplier:view');
|
||||
|
||||
-- 入库完成后的唯一状态为 WAIT_SPLIT。
|
||||
-- Finance 权限和角色授权断言必须位于五个财务列表菜单及其授权写入之后,保证整份脚本可重复执行。
|
||||
DROP PROCEDURE IF EXISTS assert_finance_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_finance_permissions()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 28 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_finance_permissions();
|
||||
DROP PROCEDURE IF EXISTS assert_finance_permissions;
|
||||
|
||||
-- IAM 跨区安全事件可靠发送箱。来源:iam/src/main/resources/db/security-event-outbox.sql
|
||||
USE baiyue_iam;
|
||||
CREATE TABLE IF NOT EXISTS iam_security_subject_version (
|
||||
auth_user_id BIGINT NOT NULL COMMENT '认证账号ID',
|
||||
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (auth_user_id)
|
||||
) ENGINE=InnoDB COMMENT='安全主体权限版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_security_tenant_version (
|
||||
tenant_id BIGINT NOT NULL COMMENT '客户ID',
|
||||
status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (tenant_id)
|
||||
) ENGINE=InnoDB COMMENT='客户状态事件版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_security_event_outbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型',
|
||||
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION',
|
||||
aggregate_id VARCHAR(64) NOT 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,
|
||||
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,
|
||||
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),
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at),
|
||||
KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱';
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_iam_security_event_schema;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_iam_security_event_schema()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_iam'
|
||||
AND table_name IN ('iam_security_subject_version','iam_security_tenant_version','iam_security_event_outbox')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件表不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
|
||||
WHERE table_schema='baiyue_iam' AND table_name='iam_security_event_outbox'
|
||||
AND index_name IN ('PRIMARY','idx_security_outbox_publish','idx_security_outbox_aggregate')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件索引不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_iam_security_event_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_iam_security_event_schema;
|
||||
|
||||
USE baiyue_firstleg;
|
||||
-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。
|
||||
ALTER TABLE fl_inbound_outbox
|
||||
MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
|
||||
ALTER TABLE fl_inbound_outbox
|
||||
MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法';
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
|
||||
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
|
||||
|
||||
+3
-3
@@ -54,7 +54,7 @@
|
||||
| `PROCESSING` | 履约处理中。 |
|
||||
| `COMPLETED` | 订单完成。 |
|
||||
|
||||
统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`INBOUNDED`、`LOADING`、`LOADED`、`DELIVERED`。
|
||||
统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`WAIT_SPLIT`、`LOADING`、`LOADED`、`DELIVERED`。
|
||||
|
||||
## 仓库与库位
|
||||
|
||||
@@ -89,13 +89,13 @@
|
||||
| `WAIT_PRICING` | 测量通过或异常放行,等待计费。 |
|
||||
| `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 |
|
||||
| `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 |
|
||||
| `INBOUNDED` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 |
|
||||
| `WAIT_SPLIT` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 |
|
||||
| `LOADING` | 已进入装柜作业,PDA 可执行拣货。 |
|
||||
| `LOADED` | 装柜完成,等待派送完成。 |
|
||||
| `DELIVERED` | 已完成派送。 |
|
||||
| `VOIDED` | 已作废,不允许继续处理。 |
|
||||
|
||||
主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `INBOUNDED` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。
|
||||
主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `WAIT_SPLIT` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。
|
||||
|
||||
### 测量、重量和费用状态
|
||||
|
||||
|
||||
+3
-1
@@ -35,7 +35,9 @@ public class EventInboxService {
|
||||
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
|
||||
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
|
||||
}
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
|
||||
boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash());
|
||||
boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash());
|
||||
if (!currentHash && !legacyHash) {
|
||||
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
|
||||
}
|
||||
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ public class PlatformQualificationFileController {
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{fileName}")
|
||||
@PreAuthorize("hasAuthority('platform:customer:view')")
|
||||
@PreAuthorize("hasAuthority('platform:customer:list')")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> download(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId, @PathVariable String fileName) {
|
||||
|
||||
@@ -20,7 +20,9 @@ public class InternalBusinessFileService {
|
||||
"CUSTOMS_RECEIPT",
|
||||
"FINANCE_VOUCHER",
|
||||
"CONTAINER_CLEARANCE_DOCUMENT",
|
||||
"LOADING_CUSTOMS_DOCUMENT");
|
||||
"LOADING_CUSTOMS_DOCUMENT",
|
||||
"CONTAINER_DELIVERY_ORDER",
|
||||
"CONTAINER_PICKUP_ORDER");
|
||||
|
||||
@Resource private FileObjectMapper files;
|
||||
@Resource private BusinessFileStorage storage;
|
||||
|
||||
@@ -82,11 +82,11 @@ class QualificationFileContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception {
|
||||
void platformQualificationPreviewUsesTheCustomerListPermission() throws Exception {
|
||||
var method = PlatformQualificationFileController.class.getDeclaredMethod(
|
||||
"download", AuthenticatedUser.class, long.class, String.class);
|
||||
|
||||
assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -46,6 +46,8 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file);
|
||||
var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file);
|
||||
var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file);
|
||||
var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file);
|
||||
|
||||
assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
@@ -53,7 +55,9 @@ class InternalBusinessFileServiceTest {
|
||||
assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51");
|
||||
verify(storage).storeLoading(0L, 41L, file);
|
||||
assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER");
|
||||
assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER");
|
||||
verify(storage, times(3)).storeLoading(0L, 41L, file);
|
||||
verify(storage).storeLoading(0L, 51L, file);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| 一期账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
|
||||
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
|
||||
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
|
||||
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
|
||||
| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` |
|
||||
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
|
||||
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
|
||||
|
||||
@@ -9,9 +9,11 @@ import java.util.List;
|
||||
|
||||
@FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots")
|
||||
public interface FirstlegCostSnapshotClient {
|
||||
@GetMapping("/customers/{customerId}/containers/{containerId}")
|
||||
ApiResponse<ContainerSnapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long customerId, @PathVariable long containerId);
|
||||
record ContainerSnapshot(long containerId, long customerId, List<OrderVolume> orders) {}
|
||||
@GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
|
||||
ApiResponse<ContainerSnapshot> loadingPlan(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long customerId,
|
||||
@PathVariable long loadingPlanId);
|
||||
record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId,
|
||||
List<OrderVolume> orders) {}
|
||||
record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
|
||||
}
|
||||
|
||||
+3
-3
@@ -27,7 +27,7 @@ public class FinancePhase1OperationController {
|
||||
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/accounts")
|
||||
@PreAuthorize("hasAuthority('platform:finance:account:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@@ -36,7 +36,7 @@ public class FinancePhase1OperationController {
|
||||
}
|
||||
|
||||
@GetMapping("/accounts/{customerId}/ledger")
|
||||
@PreAuthorize("hasAuthority('platform:finance:account:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
public Object listDerivedCustomerAccountLedger(@PathVariable long customerId,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize,
|
||||
@@ -47,7 +47,7 @@ public class FinancePhase1OperationController {
|
||||
}
|
||||
|
||||
@GetMapping("/fee-sources/{feeSourceId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
|
||||
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
|
||||
return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId));
|
||||
}
|
||||
|
||||
+18
-6
@@ -8,6 +8,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementListEnvelope;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||
import com.baiyue.finance.service.FinanceSettlementService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -40,7 +41,7 @@ public class FinanceSettlementController {
|
||||
}
|
||||
|
||||
@GetMapping("/receivable-settlements")
|
||||
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:list')")
|
||||
@Operation(summary = "查询正式应收结算单")
|
||||
public SettlementListEnvelope receivableSettlements(
|
||||
@Parameter(description = "可选客户筛选;省略时查询全部客户")
|
||||
@@ -49,7 +50,7 @@ public class FinanceSettlementController {
|
||||
}
|
||||
|
||||
@GetMapping("/payable-settlements")
|
||||
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:list')")
|
||||
@Operation(summary = "查询正式应付结算单")
|
||||
public SettlementListEnvelope payableSettlements(
|
||||
@Parameter(description = "可选客户筛选;省略时查询全部客户")
|
||||
@@ -76,13 +77,14 @@ public class FinanceSettlementController {
|
||||
}
|
||||
|
||||
@GetMapping("/settlements/{settlementId}")
|
||||
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view')")
|
||||
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ap:list')")
|
||||
public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long settlementId) {
|
||||
SettlementDetail detail = service.getSettlement(settlementId);
|
||||
String permission = "RECEIVABLE".equals(detail.summary().direction())
|
||||
? "platform:finance:settlement-ar:view" : "platform:finance:settlement-ap:view";
|
||||
if (user == null || !user.permissions().contains(permission)) {
|
||||
String direction = detail.summary().direction();
|
||||
String listPermission = "RECEIVABLE".equals(direction)
|
||||
? "platform:finance:settlement-ar:list" : "platform:finance:settlement-ap:list";
|
||||
if (user == null || !user.permissions().contains(listPermission)) {
|
||||
throw new AccessDeniedException("无权查看该方向结算单");
|
||||
}
|
||||
return success(detail);
|
||||
@@ -98,6 +100,16 @@ public class FinanceSettlementController {
|
||||
return success(service.createSettlementPayment(settlementId, user.userId(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@PostMapping("/settlements/{settlementId}/actions")
|
||||
@PreAuthorize("hasAuthority('platform:finance:settlement:void')")
|
||||
public SettlementDetailEnvelope actionSettlement(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long settlementId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody SettlementActionRequest request) {
|
||||
return success(service.actionSettlement(settlementId, user.userId(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
private static SettlementDetailEnvelope success(SettlementDetail detail) {
|
||||
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
|
||||
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
|
||||
|
||||
@@ -45,14 +45,21 @@ public class PlatformFinanceController {
|
||||
}
|
||||
|
||||
@GetMapping("/customer-accounts/{customerId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
@Operation(summary = "客户人工应收账户及流水")
|
||||
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
|
||||
return FinanceResponses.success(queryService.customerAccount(customerId));
|
||||
}
|
||||
|
||||
@GetMapping("/customer-accounts/{customerId}/summary")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
@Operation(summary = "客户正式应收应付汇总")
|
||||
public ApiResponse<?> customerAccountSummary(@PathVariable long customerId) {
|
||||
return FinanceResponses.success(queryService.customerAccountSummary(customerId));
|
||||
}
|
||||
|
||||
@GetMapping("/fee-pool")
|
||||
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
|
||||
@Operation(summary = "客户应收应付费用池")
|
||||
public ApiResponse<?> feePool(
|
||||
@Parameter(description = "可选客户筛选;省略时查询全部客户")
|
||||
@@ -62,7 +69,7 @@ public class PlatformFinanceController {
|
||||
}
|
||||
|
||||
@GetMapping("/statements")
|
||||
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
|
||||
@Operation(summary = "客户对账单")
|
||||
public ApiResponse<?> statements(
|
||||
@Parameter(description = "可选客户筛选;省略时查询全部客户")
|
||||
@@ -87,7 +94,7 @@ public class PlatformFinanceController {
|
||||
public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); }
|
||||
|
||||
@GetMapping("/bills")
|
||||
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
|
||||
@Operation(summary = "查询平台账单")
|
||||
public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); }
|
||||
|
||||
@@ -115,11 +122,11 @@ public class PlatformFinanceController {
|
||||
}
|
||||
|
||||
@GetMapping("/bills/{billId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
|
||||
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
|
||||
|
||||
@GetMapping("/bills/{billId}/revisions")
|
||||
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
|
||||
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
|
||||
return FinanceResponses.success(queryService.billRevisions(customerId, billId));
|
||||
}
|
||||
@@ -168,12 +175,20 @@ public class PlatformFinanceController {
|
||||
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
|
||||
if ("CONTAINER_SHARED".equals(request.costType())) {
|
||||
if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次");
|
||||
var response = costSnapshotClient.container(fileToken, request.customerId(), request.containerId());
|
||||
var response = costSnapshotClient.loadingPlan(fileToken, request.customerId(), request.containerId());
|
||||
var snapshot = response == null ? null : response.data();
|
||||
if (snapshot == null || snapshot.customerId() != request.customerId() || snapshot.containerId() != request.containerId() || snapshot.orders() == null || snapshot.orders().isEmpty()) {
|
||||
if (response == null || !response.successful()) {
|
||||
throw new IllegalArgumentException(response == null || response.message() == null
|
||||
? "Firstleg柜内订单体积快照不可用" : response.message());
|
||||
}
|
||||
if (snapshot == null || snapshot.customerId() != request.customerId()
|
||||
|| snapshot.loadingPlanId() != request.containerId()
|
||||
|| snapshot.containerBookingId() <= 0
|
||||
|| snapshot.orders() == null || snapshot.orders().isEmpty()) {
|
||||
throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照");
|
||||
}
|
||||
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList();
|
||||
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(),
|
||||
v.inboundId(), snapshot.loadingPlanId(), snapshot.customerId(), v.volumeCbm())).toList();
|
||||
}
|
||||
return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
|
||||
}
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ public class ReconciliationStatementController {
|
||||
}
|
||||
|
||||
@GetMapping("/reconciliation-statements/{statementId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
|
||||
public ApiResponse<ReconciliationDetail> getReconciliationStatement(@PathVariable long statementId) {
|
||||
return FinanceResponses.success(service.detailForPlatform(statementId));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.baiyue.finance.dto;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Stable read projections used by the Finance pages. */
|
||||
@@ -18,6 +19,17 @@ public final class FinanceReadModels {
|
||||
BigDecimal outstandingAmount,
|
||||
List<AccountEntry> entries) {}
|
||||
|
||||
public record CustomerAccountSummary(
|
||||
long customerId,
|
||||
String currency,
|
||||
BigDecimal totalReceivable,
|
||||
BigDecimal receivedAmount,
|
||||
BigDecimal outstandingReceivable,
|
||||
BigDecimal pendingBilling,
|
||||
BigDecimal outstandingPayable,
|
||||
Instant asOf,
|
||||
LocalDateTime lastChangedAt) {}
|
||||
|
||||
public record AccountEntry(
|
||||
String entryType,
|
||||
long resourceId,
|
||||
@@ -56,7 +68,11 @@ public final class FinanceReadModels {
|
||||
BigDecimal outstandingAmount,
|
||||
String currency,
|
||||
String status,
|
||||
int version) {}
|
||||
int version,
|
||||
long createdBy,
|
||||
LocalDateTime createdAt,
|
||||
long updatedBy,
|
||||
LocalDateTime updatedAt) {}
|
||||
|
||||
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ public final class FinanceSettlementDtos {
|
||||
public record CreateSettlementPaymentRequest(LocalDate transactionDate, BigDecimal amount, String currency,
|
||||
String method, String referenceNo, List<String> voucherFileIds,
|
||||
Integer expectedVersion, String remark) {}
|
||||
public record SettlementActionRequest(String action, String reason, Integer expectedVersion) {}
|
||||
|
||||
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
|
||||
String customerName, Long supplierId, String supplierName,
|
||||
|
||||
@@ -23,7 +23,8 @@ public final class ReconciliationStatementDtos {
|
||||
int creditDays, String currency, BigDecimal totalReceivable,
|
||||
BigDecimal receivedAmount, BigDecimal outstandingAmount,
|
||||
boolean settled, String status, int version,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt, String remark,
|
||||
long createdBy, LocalDateTime createdAt,
|
||||
long updatedBy, LocalDateTime updatedAt, String remark,
|
||||
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
|
||||
List<ReconciliationRevision> revisions) {}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.CustomerAccountSummary;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.CostAllocation;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.CostDetail;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
|
||||
@@ -17,6 +18,7 @@ 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.jdbc.BadSqlGrammarException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -26,6 +28,7 @@ import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
@@ -58,6 +61,34 @@ public class FinanceQueryService {
|
||||
return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries));
|
||||
}
|
||||
|
||||
public CustomerAccountSummary customerAccountSummary(long customerId) {
|
||||
requireCustomer(customerId);
|
||||
String sql = "SELECT " +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.total_amount ELSE 0 END),0) total_receivable," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0) received_amount," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_receivable," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
|
||||
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
|
||||
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
|
||||
"AND f.review_status='APPROVED' AND f.settlement_included=1 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
|
||||
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
|
||||
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
|
||||
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')) last_changed_at " +
|
||||
"FROM fin_settlement s WHERE s.tenant_id=?";
|
||||
try {
|
||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
||||
money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
||||
customerId, customerId, customerId);
|
||||
} catch (BadSqlGrammarException ignored) {
|
||||
// Empty/partially migrated Finance databases may not have a settlement projection yet.
|
||||
// The read-only account summary must still be usable as a zero CNY baseline.
|
||||
return new CustomerAccountSummary(customerId, CNY, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Instant.now(), null);
|
||||
}
|
||||
}
|
||||
|
||||
public List<FeePoolItem> feePool(Long customerId, String direction) {
|
||||
validateOptionalCustomer(customerId);
|
||||
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
@@ -93,22 +124,24 @@ public class FinanceQueryService {
|
||||
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
|
||||
validateOptionalCustomer(customerId);
|
||||
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
||||
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE 1=1";
|
||||
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE 1=1";
|
||||
Object[] args = new Object[0];
|
||||
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
|
||||
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
|
||||
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
|
||||
rs.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")), args);
|
||||
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
||||
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), args);
|
||||
}
|
||||
|
||||
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,
|
||||
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,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
|
||||
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
|
||||
rs.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);
|
||||
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
||||
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), 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"),
|
||||
@@ -198,6 +231,11 @@ public class FinanceQueryService {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private static BigDecimal money(ResultSet rs, String column) throws SQLException {
|
||||
BigDecimal value = rs.getBigDecimal(column);
|
||||
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;
|
||||
|
||||
@@ -4,14 +4,12 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -26,14 +24,12 @@ import java.util.Map;
|
||||
public class FinanceService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
@Autowired(required = false)
|
||||
private FinanceFileClient fileClient;
|
||||
@org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token:}")
|
||||
private String fileToken;
|
||||
private final FinanceVoucherValidator voucherValidator;
|
||||
|
||||
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.voucherValidator = voucherValidator;
|
||||
}
|
||||
|
||||
public Map<String, Object> customerTerms(long tenantId) {
|
||||
@@ -203,20 +199,8 @@ public class FinanceService {
|
||||
}
|
||||
|
||||
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
|
||||
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);
|
||||
var metadata = response == null ? null : response.data();
|
||||
if (metadata == null || metadata.fileId() == null || !tenantIdEquals(metadata.tenantId(), tenantId)
|
||||
|| !"FINANCE_VOUCHER".equals(metadata.businessType()) || metadata.contentType() == null
|
||||
|| !metadata.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) {
|
||||
throw bad("凭证文件不存在、归属不匹配或不是有效图片");
|
||||
}
|
||||
}
|
||||
voucherValidator.validate(tenantId, fileIds, "财务凭证");
|
||||
}
|
||||
private static boolean tenantIdEquals(Long actual, long expected) { return actual != null && actual == expected; }
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
|
||||
@@ -9,11 +8,11 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -46,15 +45,12 @@ public class FinanceSettlementService {
|
||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceFileClient fileClient;
|
||||
private final FinanceVoucherValidator voucherValidator;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token:}")
|
||||
private String fileToken;
|
||||
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceFileClient fileClient) {
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.fileClient = fileClient;
|
||||
this.voucherValidator = voucherValidator;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -250,6 +246,43 @@ public class FinanceSettlementService {
|
||||
return getSettlement(settlementId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SettlementDetail actionSettlement(long settlementId, long operatorId, String key,
|
||||
SettlementActionRequest request) {
|
||||
validateSettlementAction(request);
|
||||
Map<String, Object> settlement = one(
|
||||
"SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " +
|
||||
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
|
||||
long customerId = number(settlement, "tenant_id").longValue();
|
||||
Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST",
|
||||
"/api/platform/finance/v1/settlements/" + settlementId + "/actions", request);
|
||||
if (replay != null) return getSettlement(replay);
|
||||
if (number(settlement, "version").intValue() != request.expectedVersion()) {
|
||||
throw error("FIN_409", "结算单版本冲突");
|
||||
}
|
||||
String currentStatus = text(settlement, "status");
|
||||
String targetStatus;
|
||||
if ("VOID".equals(request.action())) {
|
||||
if (!"ISSUED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
|
||||
throw error("FIN_409", "只有未发生收付款的生效结算单可以作废");
|
||||
}
|
||||
targetStatus = "VOIDED";
|
||||
} else {
|
||||
if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
|
||||
throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开");
|
||||
}
|
||||
targetStatus = "ISSUED";
|
||||
}
|
||||
int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " +
|
||||
"WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId,
|
||||
request.expectedVersion());
|
||||
if (changed != 1) throw error("FIN_409", "结算单版本冲突");
|
||||
audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement),
|
||||
settlementStateSnapshot(settlement, targetStatus), operatorId);
|
||||
complete(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, settlementId);
|
||||
return getSettlement(settlementId);
|
||||
}
|
||||
|
||||
private long insertSettlement(long customerId, String no, String direction, String customerName,
|
||||
Long supplierId, String supplierName, Long reconciliationId,
|
||||
String businessPeriod, String currency, BigDecimal total,
|
||||
@@ -308,29 +341,18 @@ public class FinanceSettlementService {
|
||||
if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "付款备注不能超过500字");
|
||||
}
|
||||
|
||||
private void validateVoucherFiles(long customerId, List<String> fileIds) {
|
||||
if (fileClient == null || !StringUtils.hasText(fileToken)) throw error("FIN_503", "File服务校验能力不可用");
|
||||
for (String raw : fileIds) {
|
||||
long fileId;
|
||||
try { fileId = Long.parseLong(raw); } catch (RuntimeException invalid) { throw error("FIN_400", "付款凭证文件ID无效"); }
|
||||
FinanceFileClient.Metadata metadata;
|
||||
try {
|
||||
var response = fileClient.metadata(fileToken, customerId, customerId, fileId);
|
||||
metadata = response == null ? null : response.data();
|
||||
} catch (RuntimeException unavailable) {
|
||||
throw error("FIN_503", "File服务校验不可用");
|
||||
}
|
||||
if (metadata == null || metadata.fileId() == null || metadata.fileId() != fileId
|
||||
|| metadata.tenantId() == null || metadata.tenantId() != customerId
|
||||
|| !"FINANCE_VOUCHER".equals(metadata.businessType())
|
||||
|| !String.valueOf(customerId).equals(metadata.businessId())
|
||||
|| !StringUtils.hasText(metadata.contentType())
|
||||
|| !metadata.contentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
|
||||
throw error("FIN_400", "付款凭证不存在、客户归属不匹配或文件类型无效");
|
||||
}
|
||||
private void validateSettlementAction(SettlementActionRequest request) {
|
||||
if (request == null || !Set.of("VOID", "REOPEN").contains(request.action())
|
||||
|| !StringUtils.hasText(request.reason()) || request.reason().length() > 500
|
||||
|| request.expectedVersion() == null || request.expectedVersion() < 0) {
|
||||
throw error("FIN_400", "结算单操作、原因或版本无效");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateVoucherFiles(long customerId, List<String> fileIds) {
|
||||
voucherValidator.validate(customerId, fileIds, "付款凭证");
|
||||
}
|
||||
|
||||
private Long reserve(long customerId, long operatorId, String operation, String key,
|
||||
String method, String path, Object body) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key");
|
||||
@@ -435,6 +457,16 @@ public class FinanceSettlementService {
|
||||
"outstandingAmount", totalAmount.subtract(paidAmount));
|
||||
}
|
||||
|
||||
private static Map<String, Object> settlementStateSnapshot(Map<String, Object> settlement) {
|
||||
return settlementStateSnapshot(settlement, text(settlement, "status"));
|
||||
}
|
||||
|
||||
private static Map<String, Object> settlementStateSnapshot(Map<String, Object> settlement, String status) {
|
||||
return Map.of("totalAmount", decimal(settlement, "total_amount"),
|
||||
"paidAmount", decimal(settlement, "paid_amount"),
|
||||
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
||||
}
|
||||
|
||||
private String documentNo(String prefix) {
|
||||
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
|
||||
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.HashSet;
|
||||
|
||||
@Component
|
||||
public class FinanceVoucherValidator {
|
||||
private final FinanceFileClient fileClient;
|
||||
private final String fileToken;
|
||||
|
||||
@Autowired
|
||||
public FinanceVoucherValidator(FinanceFileClient fileClient,
|
||||
@Value("${baiyue.security.internal-service-token:}") String fileToken) {
|
||||
this.fileClient = fileClient;
|
||||
this.fileToken = fileToken;
|
||||
}
|
||||
|
||||
public void validate(long customerId, List<String> fileIds, String message) {
|
||||
if (fileIds == null || fileIds.isEmpty()) throw bad(message + "不能为空");
|
||||
if (fileClient == null || !StringUtils.hasText(fileToken)) throw unavailable("File服务校验能力不可用");
|
||||
if (new HashSet<>(fileIds).size() != fileIds.size()) throw bad(message + "文件ID不能重复");
|
||||
for (String raw : fileIds) {
|
||||
final long fileId;
|
||||
try { fileId = Long.parseLong(raw); } catch (RuntimeException invalid) { throw bad(message + "文件ID无效"); }
|
||||
FinanceFileClient.Metadata metadata;
|
||||
try {
|
||||
var response = fileClient.metadata(fileToken, customerId, customerId, fileId);
|
||||
metadata = response == null ? null : response.data();
|
||||
} catch (RuntimeException unavailable) {
|
||||
throw unavailable("File服务校验不可用");
|
||||
}
|
||||
if (metadata == null || metadata.fileId() == null || metadata.fileId() != fileId
|
||||
|| metadata.tenantId() == null || metadata.tenantId() != customerId
|
||||
|| !String.valueOf(customerId).equals(metadata.businessId())
|
||||
|| !"FINANCE_VOUCHER".equals(metadata.businessType())
|
||||
|| !StringUtils.hasText(metadata.contentType())
|
||||
|| !metadata.contentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
|
||||
throw bad(message + "不存在、归属不匹配或不是有效图片");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
private static BizException unavailable(String message) {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -169,8 +169,8 @@ public class ReconciliationStatementService {
|
||||
(rs, row) -> revision(rs, row + 1), customerId, statementId);
|
||||
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
|
||||
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
|
||||
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.createdAt(),
|
||||
bill.updatedAt(), bill.remark(), lines, payments, revisions);
|
||||
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
|
||||
bill.createdBy(), bill.createdAt(), bill.updatedBy(), bill.updatedAt(), bill.remark(), lines, payments, revisions);
|
||||
}
|
||||
|
||||
public ReconciliationDetail detailForPlatform(long statementId) {
|
||||
@@ -420,7 +420,8 @@ public class ReconciliationStatementService {
|
||||
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
|
||||
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
|
||||
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
|
||||
rs.getInt("credit_days"), rs.getString("remark"), rs.getTimestamp("created_at").toLocalDateTime(),
|
||||
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
|
||||
rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
|
||||
rs.getTimestamp("updated_at").toLocalDateTime());
|
||||
}
|
||||
|
||||
@@ -461,5 +462,6 @@ public class ReconciliationStatementService {
|
||||
String feeItemName, BigDecimal amount) {}
|
||||
private record Bill(long id, long customerId, String number, String customerName, String status,
|
||||
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
|
||||
int creditDays, String remark, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
int creditDays, String remark, long createdBy, LocalDateTime createdAt,
|
||||
long updatedBy, LocalDateTime updatedAt) {}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ CREATE TABLE IF NOT EXISTS fin_subject (
|
||||
UNIQUE KEY uk_fin_subject_code (subject_code)
|
||||
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
|
||||
|
||||
INSERT INTO fin_subject
|
||||
(subject_code, subject_name, status, version, created_by, updated_by)
|
||||
VALUES
|
||||
('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
@@ -290,3 +297,77 @@ CREATE TABLE IF NOT EXISTS fin_audit_log (
|
||||
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
|
||||
KEY idx_fin_audit_operator (operator_id, created_at)
|
||||
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_settlement (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
settlement_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一结算单号',
|
||||
direction VARCHAR(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付',
|
||||
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '结算生成时客户名称快照',
|
||||
supplier_id BIGINT UNSIGNED NULL COMMENT '应付结算供应商ID;应收结算为空',
|
||||
supplier_name_snapshot VARCHAR(200) NULL COMMENT '应付结算供应商名称快照',
|
||||
reconciliation_statement_id BIGINT UNSIGNED NULL COMMENT '应收结算关联的已出账客户对账单ID',
|
||||
business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
|
||||
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数',
|
||||
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数',
|
||||
generated_date DATE NOT NULL COMMENT '结算单生成日期',
|
||||
due_date DATE NOT NULL COMMENT '结算到期日期',
|
||||
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
|
||||
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_settlement_no (tenant_id, settlement_no),
|
||||
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
|
||||
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
|
||||
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
|
||||
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
|
||||
CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount)
|
||||
) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_settlement_line (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
|
||||
fee_source_id BIGINT UNSIGNED NOT NULL COMMENT '结算固化的Finance费用来源ID',
|
||||
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_settlement_line_source (settlement_id, fee_source_id),
|
||||
KEY idx_fin_settlement_line_customer (tenant_id, settlement_id),
|
||||
KEY idx_fin_settlement_line_source (fee_source_id),
|
||||
CONSTRAINT chk_fin_settlement_line_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_settlement_line_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_settlement_line_amount CHECK (amount > 0)
|
||||
) ENGINE=InnoDB COMMENT='正式结算单费用来源金额快照明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_settlement_payment (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
|
||||
transaction_type VARCHAR(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款',
|
||||
transaction_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收付款业务号',
|
||||
transaction_date DATE NOT NULL COMMENT '实际收款或付款日期',
|
||||
amount DECIMAL(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
method VARCHAR(32) NOT NULL COMMENT '人工登记的收付款方式',
|
||||
reference_no VARCHAR(128) NULL COMMENT '银行流水号或外部参考号',
|
||||
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
|
||||
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
|
||||
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_settlement_payment_no (tenant_id, transaction_no),
|
||||
KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at),
|
||||
CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
|
||||
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
|
||||
import com.baiyue.finance.controller.PlatformFinanceController;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary;
|
||||
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.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.springframework.jdbc.BadSqlGrammarException;
|
||||
|
||||
class CustomerAccountSummaryBehaviorTest {
|
||||
|
||||
@Test
|
||||
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "80.00");
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
|
||||
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
|
||||
CustomerAccountSummary result = service.customerAccountSummary(9L);
|
||||
|
||||
assertThat(result.customerId()).isEqualTo(9L);
|
||||
assertThat(result.currency()).isEqualTo("CNY");
|
||||
assertThat(result.totalReceivable()).isEqualByComparingTo("300.00");
|
||||
assertThat(result.receivedAmount()).isEqualByComparingTo("140.00");
|
||||
assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00");
|
||||
assertThat(result.pendingBilling()).isEqualByComparingTo("25.00");
|
||||
assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00");
|
||||
|
||||
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).queryForObject(sql.capture(), any(RowMapper.class),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L));
|
||||
assertThat(sql.getValue())
|
||||
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
||||
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
||||
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
|
||||
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.settlement_included=1")
|
||||
.contains("b.status<>'VOIDED'")
|
||||
.doesNotContain("JSON_EXTRACT")
|
||||
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCustomerReturnsZeroAmounts() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
ResultSet rs = summaryRow("0", "0", "0", "0", "0");
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
|
||||
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
||||
|
||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
|
||||
|
||||
assertThat(result.totalReceivable()).isZero();
|
||||
assertThat(result.receivedAmount()).isZero();
|
||||
assertThat(result.outstandingReceivable()).isZero();
|
||||
assertThat(result.pendingBilling()).isZero();
|
||||
assertThat(result.outstandingPayable()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSettlementProjectionReturnsZeroCnyBaseline() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any()))
|
||||
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
|
||||
|
||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
|
||||
|
||||
assertThat(result.customerId()).isEqualTo(88L);
|
||||
assertThat(result.currency()).isEqualTo("CNY");
|
||||
assertThat(result.totalReceivable()).isZero();
|
||||
assertThat(result.receivedAmount()).isZero();
|
||||
assertThat(result.outstandingReceivable()).isZero();
|
||||
assertThat(result.pendingBilling()).isZero();
|
||||
assertThat(result.outstandingPayable()).isZero();
|
||||
assertThat(result.lastChangedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerUsesListPermissionAndDelegatesSummary() throws Exception {
|
||||
FinanceQueryService query = mock(FinanceQueryService.class);
|
||||
PlatformFinanceController controller = new PlatformFinanceController(mock(FinanceService.class),
|
||||
mock(FinanceFileClient.class), mock(FirstlegCostSnapshotClient.class), query);
|
||||
|
||||
controller.customerAccountSummary(11L);
|
||||
|
||||
verify(query).customerAccountSummary(11L);
|
||||
PreAuthorize permission = PlatformFinanceController.class
|
||||
.getMethod("customerAccountSummary", long.class).getAnnotation(PreAuthorize.class);
|
||||
assertThat(permission.value()).isEqualTo("hasAuthority('platform:finance:customer-account:list')");
|
||||
}
|
||||
|
||||
private static ResultSet summaryRow(String total, String paid, String outstanding,
|
||||
String pending, String payable) throws Exception {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total));
|
||||
when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid));
|
||||
when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding));
|
||||
when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending));
|
||||
when(rs.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable));
|
||||
when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0)));
|
||||
return rs;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class FinanceContractTest {
|
||||
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
|
||||
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
|
||||
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
|
||||
assertTrue(controller.contains("costSnapshotClient.container") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
||||
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
||||
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
||||
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
|
||||
assertTrue(operations.contains("bl.source_id=f.id"));
|
||||
|
||||
@@ -15,18 +15,20 @@ class FinancePhase1PermissionContractTest {
|
||||
@Test
|
||||
void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception {
|
||||
assertEndpoint("listDerivedCustomerAccounts", GetMapping.class,
|
||||
"hasAuthority('platform:finance:account:view')", int.class, int.class, String.class, String.class);
|
||||
"platform:finance:customer-account:list",
|
||||
int.class, int.class, String.class, String.class);
|
||||
assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class,
|
||||
"hasAuthority('platform:finance:account:view')", long.class, int.class, int.class,
|
||||
"platform:finance:customer-account:list",
|
||||
long.class, int.class, int.class,
|
||||
String.class, java.time.LocalDate.class, java.time.LocalDate.class);
|
||||
assertEndpoint("getFinanceFeeSource", GetMapping.class,
|
||||
"hasAuthority('platform:finance:fee-source:view')", long.class);
|
||||
"platform:finance:customer-arap:list", long.class);
|
||||
assertEndpoint("reviewFinanceFeeSource", PostMapping.class,
|
||||
"hasAuthority('platform:finance:fee-source:review')",
|
||||
"platform:finance:fee-source:review",
|
||||
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
|
||||
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
|
||||
assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class,
|
||||
"hasAuthority('platform:finance:fee-source:settlement')",
|
||||
"platform:finance:fee-source:settlement",
|
||||
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
|
||||
com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class);
|
||||
}
|
||||
@@ -37,6 +39,7 @@ class FinancePhase1PermissionContractTest {
|
||||
Class<?>... parameterTypes) throws Exception {
|
||||
Method method = FinancePhase1OperationController.class.getMethod(methodName, parameterTypes);
|
||||
assertThat(method.isAnnotationPresent(mapping)).isTrue();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(authority);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).doesNotContain(":view");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.controller.PlatformFinanceController;
|
||||
import com.baiyue.finance.controller.FinanceSettlementController;
|
||||
import com.baiyue.finance.controller.FinancePhase1OperationController;
|
||||
import com.baiyue.finance.controller.ReconciliationStatementController;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -28,12 +30,37 @@ class FinancePlatformListContractTest {
|
||||
@Test
|
||||
void platformListPermissionsRemainSeparatedByReceivableAndPayableData() throws Exception {
|
||||
Map<String, String> expected = Map.of(
|
||||
"feePool", "hasAuthority('platform:finance:bill:view')",
|
||||
"statements", "hasAuthority('platform:finance:bill:view')",
|
||||
"receivableSettlements", "hasAuthority('platform:finance:settlement-ar:view')",
|
||||
"payableSettlements", "hasAuthority('platform:finance:settlement-ap:view')");
|
||||
"feePool", "platform:finance:customer-arap:list",
|
||||
"statements", "platform:finance:customer-statement:list",
|
||||
"receivableSettlements", "platform:finance:settlement-ar:list",
|
||||
"payableSettlements", "platform:finance:settlement-ap:list");
|
||||
for (var entry : expected.entrySet()) {
|
||||
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).isEqualTo(entry.getValue());
|
||||
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value())
|
||||
.contains(entry.getValue())
|
||||
.doesNotContain(":view");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformReadEndpointsDoNotAcceptLegacyViewAuthorities() {
|
||||
String[] legacyAuthorities = {
|
||||
"platform:finance:account:view",
|
||||
"platform:finance:fee-source:view",
|
||||
"platform:finance:bill:view",
|
||||
"platform:finance:settlement-ar:view",
|
||||
"platform:finance:settlement-ap:view"
|
||||
};
|
||||
for (Class<?> controller : new Class<?>[]{
|
||||
PlatformFinanceController.class,
|
||||
FinancePhase1OperationController.class,
|
||||
ReconciliationStatementController.class,
|
||||
FinanceSettlementController.class
|
||||
}) {
|
||||
for (Method method : controller.getDeclaredMethods()) {
|
||||
PreAuthorize permission = method.getAnnotation(PreAuthorize.class);
|
||||
if (permission == null) continue;
|
||||
assertThat(permission.value()).doesNotContain(legacyAuthorities);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.service.FinanceService;
|
||||
import com.baiyue.finance.service.FinanceVoucherValidator;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
@@ -18,7 +19,6 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
class FinanceServiceValidationTest {
|
||||
@Test
|
||||
@@ -49,7 +49,7 @@ class FinanceServiceValidationTest {
|
||||
@Test
|
||||
void rejectsUnsupportedCostTypeBeforeWrite() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
FinanceService service = service(jdbc);
|
||||
var request = new FinanceService.CostRequest("C-1", "TICKET", 1L, null, null, null, null, "MANUAL", "拖车", BigDecimal.ONE, List.of("f"), null);
|
||||
assertThrows(RuntimeException.class, () -> service.createCost(1L, 2L, request, "k1"));
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), (Object[]) any());
|
||||
@@ -58,7 +58,7 @@ class FinanceServiceValidationTest {
|
||||
@Test
|
||||
void rejectsNonPositiveAllocation() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
FinanceService service = service(jdbc);
|
||||
var request = new FinanceService.ReceiptRequest("R-1", 1L, null, "BANK_TRANSFER", null, List.of("f"), null,
|
||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ZERO)));
|
||||
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k2"));
|
||||
@@ -67,7 +67,7 @@ class FinanceServiceValidationTest {
|
||||
@Test
|
||||
void rejectsDuplicateBillAllocationInsteadOfMerging() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
FinanceService service = service(jdbc);
|
||||
var request = new FinanceService.ReceiptRequest("R-2", 1L, null, "BANK_TRANSFER", null, List.of("f"), null,
|
||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE), new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
||||
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3"));
|
||||
@@ -76,7 +76,8 @@ class FinanceServiceValidationTest {
|
||||
@Test
|
||||
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper(),
|
||||
new FinanceVoucherValidator(null, ""));
|
||||
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
|
||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
||||
|
||||
@@ -92,8 +93,7 @@ class FinanceServiceValidationTest {
|
||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||
when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(
|
||||
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L)));
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
||||
FinanceService service = service(jdbc, files);
|
||||
var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
|
||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
||||
assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4"));
|
||||
@@ -117,9 +117,7 @@ class FinanceServiceValidationTest {
|
||||
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
||||
return 1;
|
||||
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
||||
FinanceService service = service(jdbc, files);
|
||||
var request = new FinanceService.ReceiptRequest("R-4", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
|
||||
"BANK_TRANSFER", null, List.of("77"), "首笔收款",
|
||||
List.of(new FinanceService.ReceiptAllocation(50L, new BigDecimal("40.00"))));
|
||||
@@ -154,9 +152,7 @@ class FinanceServiceValidationTest {
|
||||
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
||||
return 1;
|
||||
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
||||
FinanceService service = service(jdbc, files);
|
||||
var request = new FinanceService.ReceiptRequest("R-300", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
|
||||
"BANK_TRANSFER", "BANK-300", List.of("77"), "三单收款", List.of(
|
||||
new FinanceService.ReceiptAllocation(50L, new BigDecimal("100.00")),
|
||||
@@ -176,7 +172,7 @@ class FinanceServiceValidationTest {
|
||||
void rejectsSingleOrderWhenOrderNotOwnedByCustomer() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Number.class), any(), any())).thenReturn(0);
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
FinanceService service = service(jdbc);
|
||||
var request = new FinanceService.CostRequest("C-2", "SINGLE_ORDER", 99L, null, null, null, null, "MANUAL", "货拉拉", BigDecimal.ONE, List.of("1"), null);
|
||||
assertThrows(RuntimeException.class, () -> service.createCost(9L, 2L, request, "k5"));
|
||||
}
|
||||
@@ -198,9 +194,7 @@ class FinanceServiceValidationTest {
|
||||
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
||||
return 1;
|
||||
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
||||
FinanceService service = service(jdbc, files);
|
||||
var request = new FinanceService.CostRequest("C-3", "CONTAINER_SHARED", null, null, 88L,
|
||||
null, null, "MANUAL", "整柜成本", new BigDecimal("300.00"), List.of("77"), null);
|
||||
var snapshot = List.of(new FinanceService.ContainerOrderVolume(
|
||||
@@ -221,7 +215,7 @@ class FinanceServiceValidationTest {
|
||||
.thenReturn(1);
|
||||
var rows = List.<java.util.Map<String, Object>>of(java.util.Map.of("version", 2));
|
||||
when(jdbc.queryForList(startsWith("SELECT id,bill_id,version"), eq(9L), eq(50L))).thenReturn(rows);
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
FinanceService service = service(jdbc);
|
||||
|
||||
assertEquals(rows, service.billRevisions(9L, 50L));
|
||||
}
|
||||
@@ -231,9 +225,19 @@ class FinanceServiceValidationTest {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill"), eq(Number.class), eq(50L), eq(9L)))
|
||||
.thenReturn(0);
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
FinanceService service = service(jdbc);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.billRevisions(9L, 50L));
|
||||
verify(jdbc, never()).queryForList(startsWith("SELECT id,bill_id,version"), (Object[]) any());
|
||||
}
|
||||
|
||||
private static FinanceService service(JdbcTemplate jdbc, FinanceFileClient files) {
|
||||
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
new FinanceVoucherValidator(files, "internal-token"));
|
||||
}
|
||||
|
||||
private static FinanceService service(JdbcTemplate jdbc) {
|
||||
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceVoucherValidator.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.controller.FinanceSettlementController;
|
||||
@@ -9,8 +11,10 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRe
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinanceSettlementService;
|
||||
import com.baiyue.finance.service.FinanceVoucherValidator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -53,7 +57,7 @@ class FinanceSettlementBehaviorTest {
|
||||
FinanceSettlementController controller = new FinanceSettlementController(controllerService);
|
||||
SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0);
|
||||
when(controllerService.getSettlement(88L)).thenReturn(payable);
|
||||
AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:view"));
|
||||
AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:list"));
|
||||
|
||||
var response = controller.getSettlement(apViewer, 88L);
|
||||
|
||||
@@ -61,7 +65,7 @@ class FinanceSettlementBehaviorTest {
|
||||
assertThat(response.data()).isSameAs(payable);
|
||||
assertThat(response.requestId()).isNotBlank();
|
||||
assertThatThrownBy(() -> controller.getSettlement(
|
||||
user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class);
|
||||
user(Set.of("platform:finance:settlement-ar:list")), 88L)).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -135,8 +139,11 @@ class FinanceSettlementBehaviorTest {
|
||||
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenThrow(new IllegalStateException("down"));
|
||||
FinanceSettlementService service = service(jdbc, fileClient);
|
||||
|
||||
assertCode(() -> service.createSettlementPayment(88L, 7L, "pay-key",
|
||||
payment(new BigDecimal("40.00"), 0)), "FIN_503");
|
||||
assertThatThrownBy(() -> service.createSettlementPayment(88L, 7L, "pay-key",
|
||||
payment(new BigDecimal("40.00"), 0)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any());
|
||||
@@ -161,6 +168,55 @@ class FinanceSettlementBehaviorTest {
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpaidSettlementCanBeVoidedAndReopenedWithAuditAndVersionCheck() {
|
||||
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
|
||||
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||
Map<String, Object> issued = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
|
||||
new BigDecimal("100.00"), 2);
|
||||
Map<String, Object> voided = settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
|
||||
new BigDecimal("100.00"), 3);
|
||||
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
|
||||
.thenReturn(List.of(issued));
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
|
||||
eq("SETTLEMENT_STATE_ACTION"), eq("void-key"))).thenReturn(List.of());
|
||||
stubSettlementDetail(jdbc, voided);
|
||||
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
|
||||
|
||||
SettlementDetail result = service.actionSettlement(88L, 7L, "void-key",
|
||||
new SettlementActionRequest("VOID", "业务取消", 2));
|
||||
|
||||
assertThat(result.summary().status()).isEqualTo("VOIDED");
|
||||
verify(jdbc).update(startsWith("UPDATE fin_settlement SET status="), eq("VOIDED"), eq(7L),
|
||||
eq(88L), eq(10L), eq(2));
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_audit_log"), eq(10L), eq("FIN_SETTLEMENT"),
|
||||
eq(88L), eq("VOID"), eq("业务取消"), any(), any(), eq(7L), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settlementActionRejectsPaidInvalidStateAndStaleVersion() {
|
||||
JdbcTemplate paidJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
|
||||
when(paidJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
|
||||
.thenReturn(List.of(settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
|
||||
new BigDecimal("60.00"), 2)));
|
||||
when(paidJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
|
||||
eq("SETTLEMENT_STATE_ACTION"), anyString())).thenReturn(List.of());
|
||||
FinanceSettlementService paidService = service(paidJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
|
||||
assertCode(() -> paidService.actionSettlement(88L, 7L, "paid-key",
|
||||
new SettlementActionRequest("VOID", "不能作废", 2)), "FIN_409");
|
||||
|
||||
JdbcTemplate staleJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
|
||||
when(staleJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
|
||||
.thenReturn(List.of(settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
|
||||
new BigDecimal("100.00"), 3)));
|
||||
when(staleJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
|
||||
eq("SETTLEMENT_STATE_ACTION"), eq("stale-action"))).thenReturn(List.of());
|
||||
FinanceSettlementService staleService = service(staleJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
|
||||
assertCode(() -> staleService.actionSettlement(88L, 7L, "stale-action",
|
||||
new SettlementActionRequest("REOPEN", "恢复结算", 2)), "FIN_409");
|
||||
verify(staleJdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
|
||||
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
|
||||
@@ -466,10 +522,8 @@ class FinanceSettlementBehaviorTest {
|
||||
}
|
||||
|
||||
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
|
||||
FinanceSettlementService service = new FinanceSettlementService(
|
||||
jdbc, new ObjectMapper().findAndRegisterModules(), fileClient);
|
||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
||||
return service;
|
||||
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"));
|
||||
}
|
||||
|
||||
private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 FinanceSettlementSchemaContractTest {
|
||||
|
||||
@Test
|
||||
void moduleSchemaCreatesFormalSettlementTablesAndSummaryIndex() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
|
||||
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
|
||||
.contains("total_amount DECIMAL(20,6)", "paid_amount DECIMAL(20,6)", "outstanding_amount DECIMAL(20,6)")
|
||||
.contains("updated_at DATETIME(3)");
|
||||
assertThat(unified)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
|
||||
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
|
||||
.contains("'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16");
|
||||
}
|
||||
|
||||
private static Path resolveUnifiedSql() {
|
||||
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
|
||||
missing.add(currentTable + "." + column.group(1));
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(13);
|
||||
assertThat(columnCount).isEqualTo(190);
|
||||
assertThat(tableCount).isEqualTo(16);
|
||||
assertThat(columnCount).isEqualTo(233);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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 FinanceSubjectSeedContractTest {
|
||||
@Test
|
||||
void moduleAndUnifiedSqlSeedOneActivePlatformSubjectIdempotently() throws Exception {
|
||||
String module = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
|
||||
|
||||
for (String sql : new String[] {module, unified}) {
|
||||
assertThat(sql)
|
||||
.contains("INSERT INTO fin_subject")
|
||||
.contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)")
|
||||
.contains("ON DUPLICATE KEY UPDATE")
|
||||
.contains("status = 'ACTIVE'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.service.FinanceVoucherValidator;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class FinanceVoucherValidatorTest {
|
||||
@Test
|
||||
void acceptsCustomerOwnedFinanceImage() {
|
||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||
when(files.metadata("token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
|
||||
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L)));
|
||||
|
||||
new FinanceVoucherValidator(files, "token").validate(9L, List.of("77"), "财务凭证");
|
||||
verify(files).metadata("token", 9L, 9L, 77L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMismatchedFileIdentityOwnershipTypeAndMime() {
|
||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||
List<FinanceFileClient.Metadata> invalid = List.of(
|
||||
new FinanceFileClient.Metadata(78L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
|
||||
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
|
||||
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "10", "receipt.jpg", "sha", "image/jpeg", 10L),
|
||||
new FinanceFileClient.Metadata(77L, 9L, "OTHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
|
||||
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.pdf", "sha", "application/pdf", 10L));
|
||||
|
||||
for (FinanceFileClient.Metadata metadata : invalid) {
|
||||
when(files.metadata(eq("token"), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(metadata));
|
||||
assertThatThrownBy(() -> new FinanceVoucherValidator(files, "token")
|
||||
.validate(9L, List.of("77"), "付款凭证"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenFileDependencyUnavailable() {
|
||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||
when(files.metadata("token", 9L, 9L, 77L)).thenThrow(new IllegalStateException("down"));
|
||||
|
||||
assertThatThrownBy(() -> new FinanceVoucherValidator(files, "token")
|
||||
.validate(9L, List.of("77"), "付款凭证"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingInvalidAndDuplicateFileIds() {
|
||||
FinanceVoucherValidator validator = new FinanceVoucherValidator(mock(FinanceFileClient.class), "token");
|
||||
assertResult(() -> validator.validate(9L, List.of(), "财务凭证"), ResultCode.BAD_REQUEST);
|
||||
assertResult(() -> validator.validate(9L, List.of("invalid"), "财务凭证"), ResultCode.BAD_REQUEST);
|
||||
assertResult(() -> validator.validate(9L, List.of("77", "77"), "付款凭证"), ResultCode.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongFileIdAndCustomerOwnership() {
|
||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||
when(files.metadata("token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
|
||||
new FinanceFileClient.Metadata(78L, 10L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L)));
|
||||
assertResult(() -> new FinanceVoucherValidator(files, "token")
|
||||
.validate(9L, List.of("77"), "付款凭证"), ResultCode.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingToken() {
|
||||
assertResult(() -> new FinanceVoucherValidator(mock(FinanceFileClient.class), "")
|
||||
.validate(9L, List.of("77"), "财务凭证"), ResultCode.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
private static void assertResult(org.assertj.core.api.ThrowableAssert.ThrowingCallable call,
|
||||
ResultCode resultCode) {
|
||||
assertThatThrownBy(call).isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(resultCode);
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -14,6 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -45,15 +46,15 @@ class PlatformFinanceControllerBehaviorTest {
|
||||
var request = new PlatformFinanceController.CostRequest(
|
||||
10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"),
|
||||
"CNY", List.of("91"), "整柜成本");
|
||||
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of(
|
||||
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of(
|
||||
new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, new BigDecimal("1.25")),
|
||||
new FirstlegCostSnapshotClient.OrderVolume(102L, 1002L, new BigDecimal("2.75"))));
|
||||
when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
|
||||
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
|
||||
when(service.createCost(eq(10L), eq(7L), any(), any(), eq("cost-key"))).thenReturn(Map.of("id", 1L));
|
||||
|
||||
controller.cost(user, "cost-key", request);
|
||||
|
||||
verify(snapshotClient).container("internal-token", 10L, 88L);
|
||||
verify(snapshotClient).loadingPlan("internal-token", 10L, 88L);
|
||||
verify(service).createCost(eq(10L), eq(7L), any(), eq(List.of(
|
||||
new FinanceService.ContainerOrderVolume(101L, 1001L, 88L, 10L, new BigDecimal("1.25")),
|
||||
new FinanceService.ContainerOrderVolume(102L, 1002L, 88L, 10L, new BigDecimal("2.75")))), eq("cost-key"));
|
||||
@@ -64,8 +65,35 @@ class PlatformFinanceControllerBehaviorTest {
|
||||
var request = new PlatformFinanceController.CostRequest(
|
||||
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
|
||||
"CNY", List.of("91"), null);
|
||||
when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(
|
||||
new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of())));
|
||||
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(
|
||||
new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of())));
|
||||
|
||||
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
|
||||
.hasMessageContaining("未返回有效柜内订单体积快照");
|
||||
verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerCostPreservesFirstlegBusinessFailure() {
|
||||
var request = new PlatformFinanceController.CostRequest(
|
||||
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
|
||||
"CNY", List.of("91"), null);
|
||||
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(
|
||||
new ApiResponse<>("A0404", "柜次不存在", null, "request", "trace", Instant.now()));
|
||||
|
||||
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
|
||||
.hasMessage("柜次不存在");
|
||||
verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerCostRejectsMismatchedLoadingPlanIdentity() {
|
||||
var request = new PlatformFinanceController.CostRequest(
|
||||
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
|
||||
"CNY", List.of("91"), null);
|
||||
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(89L, 99L, 10L, List.of(
|
||||
new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, BigDecimal.ONE)));
|
||||
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
|
||||
|
||||
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
|
||||
.hasMessageContaining("未返回有效柜内订单体积快照");
|
||||
|
||||
+5
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- 负责履约任务/分配,以及二期入库单、测量/OCR 结果、审核、FBA 计费、完成入库编排。
|
||||
- 负责履约任务/分配、入库单、测量/OCR、审核、FBA 计费、完成入库编排、客户在途柜量统计及国内出仓履约链路。
|
||||
- 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。
|
||||
- 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
|
||||
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
|
||||
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` |
|
||||
| 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
|
||||
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
||||
| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
|
||||
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
|
||||
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
|
||||
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
|
||||
| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
|
||||
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
|
||||
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
|
||||
@@ -30,7 +32,7 @@
|
||||
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
|
||||
| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` |
|
||||
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
|
||||
|
||||
## 修改约束
|
||||
@@ -43,7 +45,7 @@
|
||||
## 三期装柜与 PDA 资源结算
|
||||
|
||||
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
|
||||
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
|
||||
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<artifactId>firstleg-service</artifactId>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
||||
|
||||
@@ -17,7 +17,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
"com.baiyue.firstleg.oceanbooking.mapper",
|
||||
"com.baiyue.firstleg.outbound.dispatch.mapper",
|
||||
"com.baiyue.firstleg.portschedule.mapper",
|
||||
"com.baiyue.firstleg.customs.mapper"
|
||||
"com.baiyue.firstleg.customs.mapper",
|
||||
"com.baiyue.firstleg.customerstatistics.mapper"
|
||||
})
|
||||
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
|
||||
@EnableScheduling
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
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;
|
||||
|
||||
/** 将本地业务 Outbox 投递到跨区事件桥。 */
|
||||
@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}")
|
||||
public interface EventBridgeClient {
|
||||
@PostMapping("/internal/event-bridge/events")
|
||||
ApiResponse<Acknowledgement> receive(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
|
||||
record Acknowledgement(String eventId, String result, String receivedAt) { }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
/** Validates that a referenced File object belongs to the exact loading business resource. */
|
||||
@FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient",
|
||||
@@ -24,6 +25,10 @@ public interface LoadingDocumentFileClient {
|
||||
@PathVariable long tenantId, @PathVariable String businessType,
|
||||
@PathVariable String businessId, @PathVariable long fileId);
|
||||
|
||||
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content")
|
||||
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);
|
||||
|
||||
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
|
||||
|
||||
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
|
||||
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
|
||||
@@ -24,6 +25,12 @@ public interface OrderCargoClient {
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long orderId);
|
||||
|
||||
@GetMapping("/{tenantId}/{orderId}/inbound-snapshot")
|
||||
ApiResponse<OrderApprovedInboundCommand> inboundSnapshot(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long orderId);
|
||||
|
||||
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
|
||||
|
||||
+40
@@ -62,6 +62,33 @@ public class InternalInboundOrderController {
|
||||
return ApiResponse.success(service.createFromApprovedOrder(command));
|
||||
}
|
||||
|
||||
@PostMapping("/order-preparing")
|
||||
public ApiResponse<Long> prepareFromDraft(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody OrderApprovedInboundCommand command) {
|
||||
requireToken(token);
|
||||
requireSnapshot(command);
|
||||
return ApiResponse.success(service.prepareFromDraft(command));
|
||||
}
|
||||
|
||||
@PostMapping("/order-preparing/batch")
|
||||
public ApiResponse<List<InboundOrderReference.Item>> prepareFromDraftBatch(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody List<OrderApprovedInboundCommand> commands) {
|
||||
requireToken(token);
|
||||
if (commands == null || commands.isEmpty() || commands.size() > 200) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须为1至200条");
|
||||
}
|
||||
commands.forEach(this::requireSnapshot);
|
||||
long tenantId = commands.get(0).tenantId();
|
||||
if (commands.stream().anyMatch(command -> command.tenantId() != tenantId)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须属于同一客户");
|
||||
}
|
||||
commands.forEach(service::prepareFromDraft);
|
||||
return ApiResponse.success(service.findReferences(tenantId,
|
||||
commands.stream().map(OrderApprovedInboundCommand::orderId).distinct().toList()));
|
||||
}
|
||||
|
||||
@PostMapping("/order-references")
|
||||
public ApiResponse<List<InboundOrderReference.Item>> references(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@@ -101,4 +128,17 @@ public class InternalInboundOrderController {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSnapshot(OrderApprovedInboundCommand command) {
|
||||
if (command == null || command.tenantId() <= 0 || command.orderId() <= 0
|
||||
|| command.orderNo() == null || command.orderNo().isBlank()
|
||||
|| command.inboundCode() == null || command.inboundCode().isBlank()
|
||||
|| command.inboundCode().length() > 40
|
||||
|| command.customerName() == null || command.customerName().isBlank()
|
||||
|| command.warehouseId() == null || command.warehouseId() <= 0
|
||||
|| command.warehouseName() == null || command.warehouseName().isBlank()
|
||||
|| command.businessType() == null || command.businessType().isBlank()) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ public class MerchantInboundBillingController {
|
||||
@GetMapping("/{orderId}/inbound-billing")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
@Operation(summary = "查询订单入库结果和最新费用",
|
||||
description = "客户只读查看已达到 INBOUNDED + APPROVED + CONFIRMED 的二期费用,不需要确认费用。")
|
||||
description = "客户只读查看已达到 WAIT_SPLIT + APPROVED + CONFIRMED 的二期费用,不需要确认费用。")
|
||||
public ApiResponse<MerchantInboundBillingResponse> find(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
|
||||
return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId));
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 客户端下载本客户入库单箱唛。 */
|
||||
@Tag(name = "客户端入库箱唛")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/first-leg")
|
||||
public class MerchantInboundCartonLabelController {
|
||||
@Resource
|
||||
private InboundCartonLabelPrintService cartonLabelPrintService;
|
||||
|
||||
@Operation(summary = "生成入仓箱唛 PDF")
|
||||
@PostMapping("/inbounds/{inboundId}/carton-label-pdf")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> print(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Min(1) long inboundId,
|
||||
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
return cartonLabelPrintService.generate(
|
||||
FirstlegScopeGuard.merchant(user), inboundId, cartonIndex, requestId);
|
||||
}
|
||||
|
||||
@Operation(summary = "按订单生成入仓箱唛 PDF")
|
||||
@PostMapping("/orders/{orderId}/carton-label-pdf")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> printByOrder(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Min(1) long orderId,
|
||||
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
return cartonLabelPrintService.generateByOrder(
|
||||
FirstlegScopeGuard.merchant(user), orderId, cartonIndex, requestId);
|
||||
}
|
||||
|
||||
@Operation(summary = "批量按订单生成箱唛 PDF")
|
||||
@PostMapping("/orders/carton-label-pdf")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> printByOrders(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestBody List<Long> orderIds,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
return cartonLabelPrintService.generateByOrders(
|
||||
FirstlegScopeGuard.merchant(user), orderIds, requestId);
|
||||
}
|
||||
}
|
||||
+30
-1
@@ -15,6 +15,8 @@ import com.baiyue.firstleg.service.LoadingListService;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.*;
|
||||
import com.baiyue.firstleg.service.ContainerOverviewService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -23,6 +25,10 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -34,16 +40,39 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
public class PlatformContainerController {
|
||||
@Resource private ContainerService service;
|
||||
@Resource private LoadingListService loadingLists;
|
||||
@Resource private ContainerOverviewService overview;
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid Query query) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
|
||||
}
|
||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:view')")
|
||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
|
||||
}
|
||||
@GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id));
|
||||
}
|
||||
@PostMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ExceptionRequest request) {
|
||||
FirstlegScopeGuard.platform(user); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
|
||||
}
|
||||
@DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ExceptionRequest request) {
|
||||
FirstlegScopeGuard.platform(user); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
|
||||
}
|
||||
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
|
||||
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestPart("expectedVersion") @Min(0) int version,@RequestPart(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
|
||||
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
|
||||
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
|
||||
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
|
||||
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+7
-3
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.service.MeasurementReviewService;
|
||||
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -35,6 +36,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/api/platform/v1/inbounds")
|
||||
public class PlatformInboundMeasurementReviewController {
|
||||
@Resource private MeasurementReviewService service;
|
||||
@Resource private AutomaticInboundCompletionService automaticCompletion;
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "测量审核通过")
|
||||
@@ -50,10 +52,12 @@ public class PlatformInboundMeasurementReviewController {
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody MeasurementReviewRequest request) {
|
||||
validateScope(user, tenantId);
|
||||
// 审核服务事务在此调用返回时已提交;随后自动编排从新事务/新查询读取 WAIT_PRICING,
|
||||
// 避免在未提交的审核事务中读取旧状态。自动流程失败时保留已审核状态,原样返回业务错误,
|
||||
// 使用同一幂等键重试会从持久化阶段继续推进。
|
||||
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
|
||||
// 统一订单主线要求测量审核后直接进入 INBOUNDED,不把计费、库位或其他资料
|
||||
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
|
||||
return ApiResponse.success(null);
|
||||
return ApiResponse.success(automaticCompletion.completeApproved(
|
||||
tenantId, inboundId, user.userId(), user.username(), request.measurementId(), request.expectedVersion() + 1));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
|
||||
|
||||
+3
-3
@@ -58,7 +58,7 @@ public class PlatformLoadingListController {
|
||||
|
||||
@Operation(summary = "查询装柜详情", description = "装柜中可维护未拣货明细;完成装柜后永久只读。")
|
||||
@GetMapping("/loading-plans/{planId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:list')")
|
||||
public ApiResponse<PreloadDetail> preloadDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
@@ -91,7 +91,7 @@ public class PlatformLoadingListController {
|
||||
|
||||
@Operation(summary = "查询完成装柜清单明细", description = "已完成主单和明细全部只读。")
|
||||
@GetMapping("/loading-lists/{planId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
public ApiResponse<ConfirmedDetail> confirmedDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
@@ -101,7 +101,7 @@ public class PlatformLoadingListController {
|
||||
|
||||
@Operation(summary = "查询装柜清单出口报关文档")
|
||||
@GetMapping("/loading-lists/{planId}/customs")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
public ApiResponse<BusinessDocument> declaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
|
||||
+3
-3
@@ -2,9 +2,9 @@ package com.baiyue.firstleg.controller;
|
||||
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime;
|
||||
@Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service;
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<TruckAppointmentEntity>> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));}
|
||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
|
||||
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
|
||||
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
|
||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
|
||||
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
|
||||
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
|
||||
@PostMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> attach(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@Valid @RequestBody AttachmentRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attach(id,r,u.userId()));}
|
||||
@PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam(required=false) String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));}
|
||||
@DeleteMapping("/{id}/attachments/{attachmentId}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<Void> deleteAttachment(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@PathVariable @Positive long attachmentId){FirstlegScopeGuard.platform(u);service.deleteAttachment(id,attachmentId);return ApiResponse.success(null);}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.firstleg.customerstatistics.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
|
||||
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/customers")
|
||||
public class PlatformCustomerTransitStatisticsController {
|
||||
@Resource private CustomerTransitStatisticsService service;
|
||||
|
||||
@GetMapping("/{customerId}/container-transit-summary")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<TransitSummary> summary(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long customerId) {
|
||||
FirstlegScopeGuard.platform(user, customerId);
|
||||
return ApiResponse.success(service.summarize(customerId));
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.firstleg.customerstatistics.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class CustomerTransitStatisticsDtos {
|
||||
private CustomerTransitStatisticsDtos() {}
|
||||
|
||||
public record TransitSummary(Long customerId, long containerCount, BigDecimal totalTeu,
|
||||
long unknownContainerCount, LocalDateTime asOf, String definition) {}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.firstleg.customerstatistics.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Mapper
|
||||
public interface CustomerTransitStatisticsMapper {
|
||||
@Select("""
|
||||
SELECT #{customerId} AS customerId,
|
||||
COUNT(*) AS containerCount,
|
||||
COALESCE(SUM(teu), 0) AS totalTeu,
|
||||
SUM(CASE WHEN teu IS NULL THEN 1 ELSE 0 END) AS unknownContainerCount,
|
||||
CURRENT_TIMESTAMP(3) AS asOf
|
||||
FROM (
|
||||
SELECT DISTINCT x.customer_id, x.container_booking_id,
|
||||
CASE
|
||||
WHEN UPPER(TRIM(x.container_type)) LIKE '20%' THEN 1.0
|
||||
WHEN UPPER(TRIM(x.container_type)) LIKE '40%' THEN 2.0
|
||||
WHEN UPPER(TRIM(x.container_type)) LIKE '45%' THEN 2.25
|
||||
ELSE NULL
|
||||
END AS teu
|
||||
FROM (
|
||||
SELECT b.customer_id, c.id AS container_booking_id, c.container_type
|
||||
FROM fl_container_booking c
|
||||
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
|
||||
WHERE c.actual_sailing_at IS NOT NULL
|
||||
AND c.actual_warehouse_arrival_at IS NULL
|
||||
AND b.status NOT IN ('CANCELLED', 'FAILED')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_plan p
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id
|
||||
WHERE p.container_booking_id = c.id AND i.deleted = 0
|
||||
)
|
||||
UNION ALL
|
||||
SELECT i.account_id AS customer_id, c.id AS container_booking_id, c.container_type
|
||||
FROM fl_container_booking c
|
||||
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
|
||||
JOIN fl_loading_plan p ON p.container_booking_id = c.id
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
|
||||
WHERE c.actual_sailing_at IS NOT NULL
|
||||
AND c.actual_warehouse_arrival_at IS NULL
|
||||
AND b.status NOT IN ('CANCELLED', 'FAILED')
|
||||
AND i.account_id IS NOT NULL AND i.account_id > 0
|
||||
) x
|
||||
WHERE x.customer_id = #{customerId}
|
||||
) scoped
|
||||
""")
|
||||
SummaryRow summarize(@Param("customerId") long customerId);
|
||||
|
||||
record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu,
|
||||
Long unknownContainerCount, LocalDateTime asOf) {}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.firstleg.customerstatistics.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
|
||||
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Service
|
||||
public class CustomerTransitStatisticsService {
|
||||
private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。";
|
||||
@Resource private CustomerTransitStatisticsMapper mapper;
|
||||
|
||||
public TransitSummary summarize(long customerId) {
|
||||
if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数");
|
||||
var row = mapper.summarize(customerId);
|
||||
return new TransitSummary(customerId,
|
||||
value(row == null ? null : row.containerCount()),
|
||||
row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(),
|
||||
value(row == null ? null : row.unknownContainerCount()),
|
||||
row == null ? null : row.asOf(), DEFINITION);
|
||||
}
|
||||
|
||||
private static long value(Long value) { return value == null ? 0L : value; }
|
||||
}
|
||||
@@ -10,8 +10,8 @@ public enum InboundListTab {
|
||||
WAIT_REVIEW(List.of("MEASURE_PENDING")),
|
||||
/** 待发送费用:等待计费、费用确认或正在完成入库。 */
|
||||
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
|
||||
/** 已入库:费用已发送且入库完成。 */
|
||||
INBOUNDED(List.of("INBOUNDED"));
|
||||
/** 待分柜:入库和费用发布完成,等待形成有效分柜方案。 */
|
||||
WAIT_SPLIT(List.of("WAIT_SPLIT"));
|
||||
|
||||
private final List<String> processStatuses;
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import java.util.Set;
|
||||
public final class InboundProcessStatus {
|
||||
/** 等待录入或接收测量数据。 */
|
||||
public static final String WAIT_MEASURE = "WAIT_MEASURE";
|
||||
/** 草稿预入库:允许生成箱唛,但尚未进入正式测量流程。 */
|
||||
public static final String PREPARING = "PREPARING";
|
||||
/** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */
|
||||
public static final String MEASURING = "MEASURING";
|
||||
/** 测量已提交,等待平台审核。 */
|
||||
@@ -18,8 +20,6 @@ public final class InboundProcessStatus {
|
||||
public static final String FEE_CONFIRMING = "FEE_CONFIRMING";
|
||||
/** 费用已确认,正在执行托盘、库位占用和入库收口。 */
|
||||
public static final String INBOUNDING = "INBOUNDING";
|
||||
/** 历史已入库终态;存量数据仍允许进入分柜。 */
|
||||
public static final String INBOUNDED = "INBOUNDED";
|
||||
/** 入库和费用发布完成,等待分柜。 */
|
||||
public static final String WAIT_SPLIT = "WAIT_SPLIT";
|
||||
/** 已进入装柜作业,PDA 可执行拣货。 */
|
||||
@@ -32,8 +32,8 @@ public final class InboundProcessStatus {
|
||||
public static final String VOIDED = "VOIDED";
|
||||
|
||||
public static final Set<String> VALUES = Set.of(
|
||||
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
|
||||
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED, WAIT_SPLIT,
|
||||
PREPARING, WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
|
||||
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, WAIT_SPLIT,
|
||||
LOADING, LOADED, DELIVERED, VOIDED
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class ContainerOverviewDtos {
|
||||
private ContainerOverviewDtos() {}
|
||||
public record Overview(ContainerBookingEntityView container, Long loadingPlanId, PdaSummary pda,
|
||||
List<Document> documents, ExceptionState exception, List<ExceptionHistory> exceptionHistory,
|
||||
List<TimelineEntry> timeline) {}
|
||||
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
|
||||
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
|
||||
Integer version) {}
|
||||
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
|
||||
Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
|
||||
List<PhotoCount> photoCounts, List<Photo> photos, List<String> exceptionReasons) {}
|
||||
public record PhotoCount(String evidenceType, Integer count) {}
|
||||
public record Photo(Long evidenceId, Long tenantId, Long inboundId, String evidenceType,
|
||||
Long fileId, String fileName, String status, String downloadPath, LocalDateTime uploadedAt) {}
|
||||
public record Document(Long id, String documentType, Long fileId, String fileName, String fileHash,
|
||||
Long uploadedBy, String uploadedByName, LocalDateTime uploadedAt, Integer version, boolean completed) {}
|
||||
public record ExceptionState(boolean active, String reason, Long markedBy, String markedByName, LocalDateTime markedAt) {}
|
||||
public record ExceptionHistory(Long id, String action, String reason, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
|
||||
public record TimelineEntry(String eventType, String detail, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
|
||||
public record ExceptionRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=500) String reason) {}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ public final class InboundMeasurementResponses {
|
||||
Long measurementId,
|
||||
String sourceType,
|
||||
String measurementStatus,
|
||||
@Schema(description = "入库主单乐观锁版本,用于测量审核、驳回和异常放行", example = "3")
|
||||
Integer inboundVersion,
|
||||
@Schema(description = "测量批次乐观锁版本,用于测量草稿和明细维护", example = "4")
|
||||
Integer version,
|
||||
String originalDocumentNo,
|
||||
BigDecimal originalGrossWeightKg,
|
||||
|
||||
@@ -17,4 +17,5 @@ public final class InboundOrderReference {
|
||||
|
||||
public record Item(Long orderId, Long inboundId, String inboundCode, String processStatus) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public record UnifiedInboundQuery(
|
||||
Long tenantId,
|
||||
@Size(max = 64) String keyword,
|
||||
@Size(max = 64) String orderNo,
|
||||
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
|
||||
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED"})
|
||||
@Size(max = 32) String mainStatus,
|
||||
Long customerId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter
|
||||
@TableName("fl_container_transport_history")
|
||||
@@ -18,4 +19,5 @@ public class ContainerTransportHistoryEntity {
|
||||
private String reason;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ public class InboundCartonLabelEntity {
|
||||
private String boxCode;
|
||||
private Integer totalCartonCount;
|
||||
private Integer cartonIndex;
|
||||
private Integer labelVersion;
|
||||
private String batchStatus;
|
||||
private String cargoFingerprint;
|
||||
private LocalDateTime voidedAt;
|
||||
private String boxSpecification;
|
||||
private BigDecimal lengthCm;
|
||||
private BigDecimal widthCm;
|
||||
|
||||
+4
-4
@@ -17,13 +17,13 @@ public class InternalFinanceCostSnapshotController {
|
||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||
public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/customers/{customerId}/containers/{containerId}")
|
||||
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> container(
|
||||
@GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
|
||||
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> loadingPlan(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable long customerId, @PathVariable long containerId) {
|
||||
@PathVariable long customerId, @PathVariable long loadingPlanId) {
|
||||
if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
|
||||
}
|
||||
return ApiResponse.success(service.container(customerId, containerId));
|
||||
return ApiResponse.success(service.loadingPlan(customerId, loadingPlanId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public interface LoadingInboundMapper {
|
||||
AND loaded.process_status<>'LOADED' AND loaded.deleted=0
|
||||
SET root.process_status='LOADED', root.updated_by=#{operatorId}, root.version=root.version+1
|
||||
WHERE root.tenant_id=#{tenantId} AND root.id=#{rootInboundId} AND root.deleted=0
|
||||
AND root.inbound_role='MASTER' AND root.process_status IN ('INBOUNDED','LOADING')
|
||||
AND root.inbound_role='MASTER' AND root.process_status IN ('WAIT_SPLIT','LOADING')
|
||||
AND remainder.id IS NULL AND loaded.id IS NULL
|
||||
""")
|
||||
int aggregateRootLoaded(@Param("tenantId") long tenantId,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user