Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6877c372bd | ||
|
|
3ceafa9ca7 | ||
|
|
a43c4d3321 | ||
|
|
2ec4627db6 | ||
|
|
c324de9c32 | ||
|
|
9f3755f8e2 | ||
|
|
8e9cbf6aa2 |
@@ -108,14 +108,14 @@
|
|||||||
|
|
||||||
## 数据库访问与中文代码注释(所有服务及公共模块强制)
|
## 数据库访问与中文代码注释(所有服务及公共模块强制)
|
||||||
|
|
||||||
- 本节适用于全部后端服务和公共模块;新增代码遵守下列规范,存量分层仅在影响本次正确性或用户明确要求时整改,不因局部修改迁移整条链路;以上为目标规范,不代表存量均已达标。
|
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
|
||||||
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
|
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
|
||||||
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
|
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
|
||||||
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
|
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
|
||||||
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
|
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
|
||||||
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
|
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
|
||||||
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
|
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
|
||||||
- 本次新增或修改的手写方法及构造器须有简短中文业务注释,通常 1—3 行;接口与 Mapper 方法优先用中文 Javadoc,覆盖方法同样适用,关键规则就地说明。不补齐未修改或自动生成方法的注释。
|
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
|
||||||
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
|
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
|
||||||
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
|
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
|
||||||
|
|
||||||
@@ -132,11 +132,7 @@
|
|||||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
|
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
|
||||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||||
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||||
- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。
|
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||||
- 局部逻辑使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及必要边界即可交付,不因收尾再跑 `Module`。Java 签名变化须覆盖生产与测试源码编译;定向测试已编译时不重复跑 `-Mode Compile`。只有影响扩及服务内多个行为且定向覆盖不足才选 `Module`,先说明原因。跨服务 DTO、接口或事件变化显式列入生产者和实际消费者;`-am` 仅补上游构建依赖,不自动包含下游或 HTTP/事件消费者。
|
|
||||||
- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。
|
|
||||||
- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 POM、依赖或测试配置改动先按实际影响选编译或测试,不一律全仓。入口为 `scripts/backend-full-regression.ps1`,CI 全量保留。普通提交、单服务任务或对话收尾不触发;有效结果复用,输入变化只补跑受影响范围。
|
|
||||||
- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
|
||||||
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
||||||
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||||
|
|||||||
Vendored
+2
-35
@@ -67,8 +67,6 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
|
|||||||
List<String> failedServices = []
|
List<String> failedServices = []
|
||||||
if (state.startsWith('RESULTS')) {
|
if (state.startsWith('RESULTS')) {
|
||||||
String summary = state.substring('RESULTS'.length()).trim()
|
String summary = state.substring('RESULTS'.length()).trim()
|
||||||
int metadataIndex = summary.indexOf(' | ')
|
|
||||||
if (metadataIndex >= 0) summary = summary.substring(0, metadataIndex).trim()
|
|
||||||
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||||
int separator = item.indexOf('=')
|
int separator = item.indexOf('=')
|
||||||
if (separator > 0) {
|
if (separator > 0) {
|
||||||
@@ -105,25 +103,6 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试范围必须相对成功门禁,而非失败/取消轮次;否则补跑可能遗漏前次未通过的变更。
|
|
||||||
def previousSuccessfulTestCommit(def previousBuild, List<String> requiredServices) {
|
|
||||||
def candidate = previousBuild
|
|
||||||
int inspected = 0
|
|
||||||
while (candidate != null && inspected < 30) {
|
|
||||||
String description = candidate.description ?: ''
|
|
||||||
String marker = ' | TESTS=PASSED '
|
|
||||||
int markerIndex = description.lastIndexOf(marker)
|
|
||||||
if (candidate.result == 'SUCCESS' && markerIndex >= 0) {
|
|
||||||
List metadata = description.substring(markerIndex + marker.length()).trim().split(' ')
|
|
||||||
if (metadata.size() == 2 && metadata[0] ==~ /[0-9a-fA-F]{7,40}/ &&
|
|
||||||
requiredServices.every { metadata[1].split(',').contains(it) }) return metadata[0]
|
|
||||||
}
|
|
||||||
candidate = candidate.previousBuild
|
|
||||||
inspected++
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
def environmentChoicesForJob(String jobBaseName) {
|
def environmentChoicesForJob(String jobBaseName) {
|
||||||
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
||||||
}
|
}
|
||||||
@@ -182,9 +161,7 @@ pipeline {
|
|||||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
||||||
choice(name: 'TEST_SCOPE', choices: ['AUTO', 'MODULE'], description: 'AUTO 对已支持的局部变更执行关联测试;公共/未知变更自动完整模块回归;MODULE 显式完整模块回归')
|
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
|
||||||
// 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。
|
|
||||||
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image')
|
|
||||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||||
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
||||||
}
|
}
|
||||||
@@ -291,7 +268,6 @@ pipeline {
|
|||||||
selected = selected.unique()
|
selected = selected.unique()
|
||||||
|
|
||||||
env.SELECTED_SERVICES = selected.join(',')
|
env.SELECTED_SERVICES = selected.join(',')
|
||||||
env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected)
|
|
||||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||||
@@ -358,22 +334,16 @@ pipeline {
|
|||||||
.findAll { it }
|
.findAll { it }
|
||||||
.join(',')
|
.join(',')
|
||||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||||
String testPlan = "${pwd(tmp: true)}/ci-test-plan.json"
|
|
||||||
lock(resource: 'firstleg-test-maven') {
|
lock(resource: 'firstleg-test-maven') {
|
||||||
timeout(time: 50, unit: 'MINUTES') {
|
timeout(time: 50, unit: 'MINUTES') {
|
||||||
sh """
|
sh """
|
||||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||||
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||||
node deploy/scripts/ci-test-scope.mjs plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}'
|
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||||
node deploy/scripts/ci-test-scope.mjs checks --plan '${testPlan}'
|
|
||||||
test_arguments=\"\$(node deploy/scripts/ci-test-scope.mjs args --plan '${testPlan}')\"
|
|
||||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test \$test_arguments
|
|
||||||
node deploy/scripts/ci-test-scope.mjs verify --plan '${testPlan}'
|
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||||
env.TEST_GATE_PASSED = 'true'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
post {
|
post {
|
||||||
@@ -430,9 +400,6 @@ pipeline {
|
|||||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||||
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
||||||
: "${env.IMAGE_TAG} | ${summary}"
|
: "${env.IMAGE_TAG} | ${summary}"
|
||||||
if (env.TEST_GATE_PASSED == 'true') {
|
|
||||||
currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}"
|
|
||||||
}
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -49,8 +49,7 @@ pipeline {
|
|||||||
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||||
// 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。
|
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
|
||||||
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写')
|
|
||||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,16 +23,11 @@
|
|||||||
## 本地构建
|
## 本地构建
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 日常按受影响模块检查;跨服务变更显式列入消费者。
|
mvn -DskipTests validate
|
||||||
.\scripts\backend-check.ps1 -Modules order -Mode Compile
|
node --test scripts/java-topology-contract.test.mjs
|
||||||
.\scripts\backend-check.ps1 -Modules order
|
mvn test
|
||||||
# 根构建/拓扑变化等全量触发条件见 AGENTS.md。
|
|
||||||
.\scripts\local-check.ps1 -Command @('node', '--test', 'scripts/java-topology-contract.test.mjs')
|
|
||||||
.\scripts\backend-full-regression.ps1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
开发中可通过 `backend-check.ps1 -Mode Focused -Modules <模块> -Tests <精确测试类名>` 快速反馈;参数、DryRun 和结果边界见 [后端分层检查](scripts/README.md#后端分层检查)。单服务修改收尾执行模块回归,不默认执行上面的全仓命令。
|
|
||||||
|
|
||||||
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
||||||
|
|
||||||
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.baiyue.common.core.number;
|
|
||||||
|
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
|
||||||
import com.baiyue.common.core.exception.BizException;
|
|
||||||
import java.time.Clock;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.function.ToLongFunction;
|
|
||||||
|
|
||||||
/** 统一业务单号格式;序列仍由各服务自己的 Repository/Mapper 分配。 */
|
|
||||||
public final class DailyDocumentNumber {
|
|
||||||
public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
|
||||||
|
|
||||||
/** 工具类不允许实例化。 */
|
|
||||||
private DailyDocumentNumber() {}
|
|
||||||
|
|
||||||
/** 先固定北京时间业务日,再使用前缀和日期分配跨客户、跨仓库日流水。 */
|
|
||||||
public static String next(String prefix, Clock clock, ToLongFunction<String> sequences) {
|
|
||||||
requirePrefix(prefix);
|
|
||||||
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
|
|
||||||
String dateText = date.format(DateTimeFormatter.BASIC_ISO_DATE);
|
|
||||||
long value = sequences.applyAsLong(prefix + ":" + dateText);
|
|
||||||
return format(prefix, date, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 严格四位流水,不允许溢出后产生五位号码或退回随机编号。 */
|
|
||||||
public static String format(String prefix, LocalDate date, long value) {
|
|
||||||
requirePrefix(prefix);
|
|
||||||
if (value < 1 || value > 9999) {
|
|
||||||
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据四位流水号已用尽或分配失败");
|
|
||||||
}
|
|
||||||
return prefix + date.format(DateTimeFormatter.BASIC_ISO_DATE)
|
|
||||||
+ String.format(Locale.ROOT, "%04d", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 业务类型只能使用两至四位大写字母。 */
|
|
||||||
private static void requirePrefix(String prefix) {
|
|
||||||
if (prefix == null || !prefix.matches("[A-Z]{2,4}")) {
|
|
||||||
throw new IllegalArgumentException("业务单据前缀必须为2至4位大写字母");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package com.baiyue.common.core.number;
|
|
||||||
|
|
||||||
import com.baiyue.common.core.exception.BizException;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import java.time.*;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
|
|
||||||
class DailyDocumentNumberTest {
|
|
||||||
/** 午夜前后取号使用北京时间日期,与主机和 Clock 的时区无关。 */
|
|
||||||
@Test void rollsAtShanghaiMidnightAndKeepsPrefixesIndependent() {
|
|
||||||
var values = new HashMap<String, Long>();
|
|
||||||
java.util.function.ToLongFunction<String> allocate = key -> values.merge(key, 1L, Long::sum);
|
|
||||||
var before = Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"), ZoneOffset.UTC);
|
|
||||||
var after = Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"), ZoneId.of("America/Los_Angeles"));
|
|
||||||
assertThat(DailyDocumentNumber.next("FL", before, allocate)).isEqualTo("FL202610190001");
|
|
||||||
assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200001");
|
|
||||||
assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200002");
|
|
||||||
assertThat(DailyDocumentNumber.next("ZGRK", after, allocate)).isEqualTo("ZGRK202610200001");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 字符集和流水上限为硬边界,未知输入不能生成近似号码。 */
|
|
||||||
@Test void rejectsInvalidPrefixAndSequence() {
|
|
||||||
for (String prefix : new String[]{"F", "ABCDE", "Fl", "F1", " FL", ""}) {
|
|
||||||
assertThatThrownBy(() -> DailyDocumentNumber.format(prefix, LocalDate.of(2026, 10, 20), 1))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
|
||||||
}
|
|
||||||
for (long value : new long[]{-1, 0, 10000}) {
|
|
||||||
assertThatThrownBy(() -> DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), value))
|
|
||||||
.isInstanceOf(BizException.class);
|
|
||||||
}
|
|
||||||
assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 9999)).isEqualTo("FL202610209999");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 全局 Locale 不得让数字变为本地化字符。 */
|
|
||||||
@Test void alwaysUsesAsciiDigits() {
|
|
||||||
Locale old = Locale.getDefault();
|
|
||||||
try {
|
|
||||||
Locale.setDefault(Locale.forLanguageTag("ar"));
|
|
||||||
assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 1)).isEqualTo("FL202610200001");
|
|
||||||
} finally { Locale.setDefault(old); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,15 @@ package com.baiyue.common.event;
|
|||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
/** 跨服务业务事实使用的真实集装箱号,只规范化空白和大小写,不校验 ISO 格式。 */
|
/** Canonical physical container number used by cross-region business facts. */
|
||||||
public final class ContainerNumbers {
|
public final class ContainerNumbers {
|
||||||
|
private static final int[] LETTER_VALUES = {
|
||||||
|
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||||
|
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||||
|
};
|
||||||
|
|
||||||
private ContainerNumbers() { }
|
private ContainerNumbers() { }
|
||||||
|
|
||||||
/** 柜号必须非空;统一移除格式空白并转大写,保留业务录入值。 */
|
|
||||||
public static String normalizeRequired(String value) {
|
public static String normalizeRequired(String value) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
throw new IllegalArgumentException("containerNo is required");
|
throw new IllegalArgumentException("containerNo is required");
|
||||||
@@ -15,13 +19,21 @@ public final class ContainerNumbers {
|
|||||||
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||||
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||||
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||||
if (normalized.isEmpty()) {
|
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||||
throw new IllegalArgumentException("containerNo is required");
|
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
||||||
|
}
|
||||||
|
int sum = 0;
|
||||||
|
for (int index = 0; index < 10; index++) {
|
||||||
|
char current = normalized.charAt(index);
|
||||||
|
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
|
||||||
|
sum += number * (1 << index);
|
||||||
|
}
|
||||||
|
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
|
||||||
|
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 冻结事实必须已规范化,避免生产与消费端的快照或哈希发生漂移。 */
|
|
||||||
public static String requireCanonical(String value) {
|
public static String requireCanonical(String value) {
|
||||||
String normalized = normalizeRequired(value);
|
String normalized = normalizeRequired(value);
|
||||||
if (!normalized.equals(value)) {
|
if (!normalized.equals(value)) {
|
||||||
|
|||||||
+6
-27
@@ -1,41 +1,20 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class ContainerNumbersTest {
|
class ContainerNumbersTest {
|
||||||
/** 规范化仍保持确定性,事件只能接受已冻结的规范值。 */
|
|
||||||
@Test
|
@Test
|
||||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactValue() {
|
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
||||||
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||||
|
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
||||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||||
.hasMessageContaining("normalized");
|
.hasMessageContaining("normalized");
|
||||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(" JZXH10231043 "))
|
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
||||||
.hasMessageContaining("normalized");
|
.hasMessageContaining("check digit");
|
||||||
}
|
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
||||||
|
.hasMessageContaining("ISO 6346");
|
||||||
/** 非 ISO 柜号可以作为事件事实,不在下游重新阻断。 */
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(strings = {"JZXH10231043", "ZCSU7881971", "MSCA6639870", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
|
||||||
void acceptsNonIsoNumber(String containerNo) {
|
|
||||||
assertThat(ContainerNumbers.normalizeRequired(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " "))
|
|
||||||
.isEqualTo(containerNo);
|
|
||||||
assertThat(ContainerNumbers.requireCanonical(containerNo)).isEqualTo(containerNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 规范化前后均不得将空白柜号作为业务事实。 */
|
|
||||||
@ParameterizedTest
|
|
||||||
@NullAndEmptySource
|
|
||||||
@ValueSource(strings = {" ", "\u00a0\u3000"})
|
|
||||||
void rejectsMissingNumber(String containerNo) {
|
|
||||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired(containerNo))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
|
|
||||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(containerNo))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,14 +40,8 @@ public class GlobalExceptionHandler {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private MeterRegistry meterRegistry;
|
private MeterRegistry meterRegistry;
|
||||||
|
|
||||||
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
|
|
||||||
@ExceptionHandler(BizException.class)
|
@ExceptionHandler(BizException.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
||||||
if (exception.getResultCode().httpStatus().is5xxServerError()) {
|
|
||||||
log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}",
|
|
||||||
request.getMethod(), request.getRequestURI(), exception.getResultCode().code(),
|
|
||||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception);
|
|
||||||
}
|
|
||||||
return failure(exception.getResultCode(), exception.getMessage(), request);
|
return failure(exception.getResultCode(), exception.getMessage(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import com.baiyue.common.core.exception.BizException;
|
|||||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.springframework.boot.test.system.CapturedOutput;
|
|
||||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
|
||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
import org.springframework.core.MethodParameter;
|
import org.springframework.core.MethodParameter;
|
||||||
import org.springframework.validation.BeanPropertyBindingResult;
|
import org.springframework.validation.BeanPropertyBindingResult;
|
||||||
@@ -42,37 +39,6 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */
|
|
||||||
@Test
|
|
||||||
@ExtendWith(OutputCaptureExtension.class)
|
|
||||||
void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) {
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts");
|
|
||||||
request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request");
|
|
||||||
request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace");
|
|
||||||
request.setQueryString("keyword=private-query");
|
|
||||||
request.addHeader("X-Internal-Service-Token", "private-service-token");
|
|
||||||
BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用");
|
|
||||||
exception.initCause(new IllegalStateException("缺少尾程来源"));
|
|
||||||
|
|
||||||
var response = handler.handleBusiness(exception, request);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
|
||||||
assertThat(response.getBody().code()).isEqualTo("S0503");
|
|
||||||
assertThat(response.getBody().message()).isEqualTo("统计暂不可用");
|
|
||||||
assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts",
|
|
||||||
"S0503", "status-count-request", "status-count-trace", "缺少尾程来源")
|
|
||||||
.doesNotContain("private-query", "private-service-token");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预期的业务冲突不应升级为系统错误日志。 */
|
|
||||||
@Test
|
|
||||||
@ExtendWith(OutputCaptureExtension.class)
|
|
||||||
void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) {
|
|
||||||
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"),
|
|
||||||
new MockHttpServletRequest());
|
|
||||||
assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ spring:
|
|||||||
password: ${FINANCE_DB_PASSWORD}
|
password: ${FINANCE_DB_PASSWORD}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
# TEST 流程验收复用 Client 的示例图片,图片标注不可付款且不含真实收款信息。
|
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
|
||||||
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
|
|
||||||
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
|
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
|
||||||
baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
|
baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
|
||||||
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
|
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'
|
|
||||||
import { resolve, relative, basename } from 'node:path'
|
|
||||||
import { execFileSync } from 'node:child_process'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const moduleName = 'lastleg-service'
|
|
||||||
const testClass = /(?:^Test.+|.+(?:Test|Tests|TestCase))\.java$/
|
|
||||||
const words = source => new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
|
|
||||||
const tables = source => new Set(source.match(/\bll_[a-z0-9_]+\b/g) || [])
|
|
||||||
const writtenTables = source => new Set([...source.matchAll(/\b(?:UPDATE|INSERT\s+(?:IGNORE\s+)?INTO|DELETE\s+FROM)\s+(ll_[a-z0-9_]+)/gi)].map(match => match[1]))
|
|
||||||
|
|
||||||
// 按完整注解比较 SQL,避免只改 SELECT 时把同一 Mapper 内未改的全部写语句扩大为全域回归。
|
|
||||||
function writeStatements(source) {
|
|
||||||
const result = []
|
|
||||||
for (const match of source.matchAll(/@(?:Insert|Update|Delete)\s*\(/g)) {
|
|
||||||
let depth = 1, quote = '', index = match.index + match[0].length
|
|
||||||
for (; index < source.length && depth; index++) {
|
|
||||||
if (quote) {
|
|
||||||
if (source.startsWith(quote, index)) { index += quote.length - 1; quote = '' }
|
|
||||||
else if (source[index] === '\\' && quote.length === 1) index++
|
|
||||||
} else if (source.startsWith('"""', index)) { quote = '"""'; index += 2 }
|
|
||||||
else if (source[index] === '"' || source[index] === "'") quote = source[index]
|
|
||||||
else if (source[index] === '(') depth++
|
|
||||||
else if (source[index] === ')') depth--
|
|
||||||
}
|
|
||||||
if (depth) throw new Error('Cannot parse SQL annotation; use MODULE test scope')
|
|
||||||
result.push(source.slice(match.index, index))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function changedWrites(node) {
|
|
||||||
if (node.previousSource === undefined) return writtenTables(node.source)
|
|
||||||
const before = writeStatements(node.previousSource), after = writeStatements(node.source)
|
|
||||||
const changed = [...before.filter(sql => !after.includes(sql)), ...after.filter(sql => !before.includes(sql))]
|
|
||||||
const oldRest = before.reduce((text, sql) => text.replace(sql, ''), node.previousSource)
|
|
||||||
const newRest = after.reduce((text, sql) => text.replace(sql, ''), node.source)
|
|
||||||
if (oldRest !== newRest) changed.push(oldRest, newRest)
|
|
||||||
return writtenTables(changed.join('\n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 宁可扩大范围,不用文件名猜测唯一测试;未识别的输入保留完整模块门禁。 */
|
|
||||||
export function planTests({ modules, mode = 'AUTO', changes = [], sources = [], baseline = '' }) {
|
|
||||||
const architectureScript = `${moduleName}/scripts/analyze-lastleg-dependencies`
|
|
||||||
const staticChecks = changes.some(path => path.startsWith(architectureScript))
|
|
||||||
? [`${architectureScript}.test.mjs`] : []
|
|
||||||
const full = reason => ({ scope: 'MODULE', reason, modules, baseline, tests: [], staticChecks })
|
|
||||||
if (mode !== 'AUTO' && mode !== 'MODULE') throw new Error('TEST_SCOPE must be AUTO or MODULE')
|
|
||||||
if (mode === 'MODULE') return full('显式完整模块回归')
|
|
||||||
if (!baseline) return full('没有成功测试基线')
|
|
||||||
if (modules.length !== 1 || modules[0] !== moduleName) return full('多服务或尚未核对的服务采用完整模块回归')
|
|
||||||
const prefix = `${moduleName}/`
|
|
||||||
const relevant = changes.filter(path => path.startsWith(prefix))
|
|
||||||
const shared = changes.some(path => path !== 'scripts/agents-index-contract.ps1' && !path.startsWith(prefix)
|
|
||||||
&& /^(?:common-[^/]+\/|platform-bom\/|pom\.xml$|Jenkinsfile|deploy\/|config\/|scripts\/.*\.(?:mjs|sh|ps1)$)/.test(path))
|
|
||||||
if (shared) return full('公共能力、发布或测试基础设施变更')
|
|
||||||
if (!relevant.length) return full('补跑服务没有可验证的业务差异')
|
|
||||||
const java = `${prefix}src/main/java/com/baiyue/lastleg/`
|
|
||||||
const supported = path => path.startsWith(`${prefix}src/test/java/`)
|
|
||||||
|| path.startsWith(`${prefix}src/main/resources/openapi/`)
|
|
||||||
|| path.startsWith(architectureScript)
|
|
||||||
|| (path.startsWith(java) && path.slice(java.length).includes('/')
|
|
||||||
&& !/^(?:config|security|entity|dto|model|mapper|repository|service|client)\//.test(path.slice(java.length)))
|
|
||||||
|| /\.(?:md)$/.test(path)
|
|
||||||
if (relevant.some(path => !supported(path))) return full('共享模型、配置、SQL、资源或未识别路径变更')
|
|
||||||
|
|
||||||
const nodes = sources.map(({ path, source, previousSource }) => ({ path, source, previousSource,
|
|
||||||
symbol: basename(path, '.java'), tokens: words(source), tables: tables(source),
|
|
||||||
test: path.startsWith(`${prefix}src/test/java/`),
|
|
||||||
name: `${source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''}.${basename(path, '.java')}` }))
|
|
||||||
const byPath = new Map(nodes.map(node => [node.path, node]))
|
|
||||||
const bySymbol = new Map()
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!bySymbol.has(node.symbol)) bySymbol.set(node.symbol, [])
|
|
||||||
bySymbol.get(node.symbol).push(node)
|
|
||||||
}
|
|
||||||
const reverse = new Map(nodes.map(node => [node.path, new Set()]))
|
|
||||||
for (const node of nodes) {
|
|
||||||
// 包含反射字符串、静态 import、同包引用及测试夹具;重名类全部纳入。
|
|
||||||
for (const token of node.tokens) for (const dependency of bySymbol.get(token) || []) {
|
|
||||||
if (dependency !== node) reverse.get(dependency.path).add(node.path)
|
|
||||||
}
|
|
||||||
// 实现变化也要覆盖只依赖接口/基类的调用者,不能仅沿显式 import 反查。
|
|
||||||
for (const match of node.source.matchAll(/\b(?:implements|extends)\s+([^{};]+)/g)) {
|
|
||||||
for (const token of words(match[1])) for (const contract of bySymbol.get(token) || []) {
|
|
||||||
if (contract !== node) reverse.get(node.path).add(contract.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const seeds = new Set()
|
|
||||||
const changedTables = new Set()
|
|
||||||
for (const path of relevant.filter(path => path.endsWith('.java'))) {
|
|
||||||
const node = byPath.get(path)
|
|
||||||
if (node) {
|
|
||||||
seeds.add(path)
|
|
||||||
// 读查询变化不改变其他查询的数据;潜在写入变化则纳入同表读写消费者。
|
|
||||||
if (!node.test) for (const table of changedWrites(node)) changedTables.add(table)
|
|
||||||
} else {
|
|
||||||
// 删除/移动旧类型时,仍按旧类名查现存消费者。
|
|
||||||
const symbol = basename(path, '.java')
|
|
||||||
const consumers = nodes.filter(consumer => consumer.tokens.has(symbol))
|
|
||||||
if (!consumers.length) return full('删除类型缺少可追踪的现存消费者')
|
|
||||||
for (const consumer of consumers) seeds.add(consumer.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!seeds.size) return full('没有可追踪的 Java 行为差异')
|
|
||||||
for (const node of nodes) if ([...node.tables].some(table => changedTables.has(table))) seeds.add(node.path)
|
|
||||||
const affected = new Set(seeds)
|
|
||||||
const queue = [...seeds]
|
|
||||||
for (let index = 0; index < queue.length; index++) {
|
|
||||||
for (const consumer of reverse.get(queue[index]) || []) if (!affected.has(consumer)) {
|
|
||||||
affected.add(consumer); queue.push(consumer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const allTests = nodes.filter(node => node.test && testClass.test(basename(node.path))
|
|
||||||
&& /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/.test(node.source))
|
|
||||||
const contractChange = relevant.some(path => path.startsWith(`${prefix}src/main/resources/openapi/`))
|
|
||||||
const candidates = allTests.filter(node => affected.has(node.path)
|
|
||||||
|| /(?:Security|Isolation|MapperRegistrationArchitecture|QueryBinding)/.test(node.symbol)
|
|
||||||
|| (contractChange && /(?:Contract|OpenApi|Schema|Architecture|Permission)/.test(node.symbol)))
|
|
||||||
// 显式依赖真实环境的类仍由 MODULE/专项授权入口负责,不能把它们默认跳过算作执行成功。
|
|
||||||
const optIn = node => /@(?:EnabledIf(?:SystemProperty|EnvironmentVariable)|Disabled(?:IfSystemProperty|IfEnvironmentVariable)?\b)/
|
|
||||||
.test(node.source.slice(0, node.source.indexOf(`class ${node.symbol}`)))
|
|
||||||
const tests = candidates.filter(node => !optIn(node))
|
|
||||||
if (!tests.some(node => affected.has(node.path))) return full('没有匹配到行为测试,拒绝零测试通过')
|
|
||||||
if (tests.some(node => !/^com\.[\w.$]+$/.test(node.name))) return full('测试类名无法安全传入 Maven')
|
|
||||||
return { scope: 'AFFECTED', reason: 'Java 反向引用、接口实现、测试夹具、共享 SQL 表和相关静态合同',
|
|
||||||
modules, baseline, tests: tests.map(node => node.name).sort(), totalTests: allTests.length, staticChecks,
|
|
||||||
optInTests: candidates.filter(optIn).map(node => node.name).sort() }
|
|
||||||
}
|
|
||||||
|
|
||||||
function walk(root) {
|
|
||||||
const files = []
|
|
||||||
for (const item of readdirSync(root, { withFileTypes: true })) {
|
|
||||||
const path = resolve(root, item.name)
|
|
||||||
if (item.isDirectory()) files.push(...walk(path))
|
|
||||||
else if (item.isFile() && item.name.endsWith('.java')) files.push(path)
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 每个所选类必须有本轮实际执行的报告;允许上游零匹配不能变成整个门禁零测试。 */
|
|
||||||
export function verifyReports(plan, reports) {
|
|
||||||
if (plan.scope !== 'AFFECTED') return
|
|
||||||
for (const name of plan.tests) {
|
|
||||||
const matches = reports.filter(report => report.name === name || report.name.startsWith(`${name}$`))
|
|
||||||
if (!matches.length || matches.some(report => report.failures || report.errors
|
|
||||||
|| ['tests', 'skipped', 'failures', 'errors'].some(key => !Number.isInteger(report[key]) || report[key] < 0)
|
|
||||||
|| report.skipped > report.tests)
|
|
||||||
|| matches.reduce((sum, report) => sum + report.tests - report.skipped, 0) <= 0) {
|
|
||||||
throw new Error(`Missing, skipped or failed fresh test report: ${name}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function main(args) {
|
|
||||||
const [action, ...options] = args
|
|
||||||
const values = {}
|
|
||||||
for (let i = 0; i < options.length; i += 2) {
|
|
||||||
if (!options[i].startsWith('--') || options[i + 1] === undefined) throw new Error('Expected --name value')
|
|
||||||
values[options[i].slice(2)] = options[i + 1]
|
|
||||||
}
|
|
||||||
const root = process.cwd()
|
|
||||||
const planFile = values.plan
|
|
||||||
if (!planFile) throw new Error('--plan is required')
|
|
||||||
if (action === 'plan') {
|
|
||||||
const modules = (values.modules || '').split(',')
|
|
||||||
if (modules.some(module => !/^[a-z][a-z0-9-]+$/.test(module))) throw new Error('Invalid modules')
|
|
||||||
let baseline = values.base || ''
|
|
||||||
let changes = []
|
|
||||||
if (baseline) {
|
|
||||||
if (!/^[0-9a-f]{7,40}$/i.test(baseline)) throw new Error('Invalid baseline commit')
|
|
||||||
try {
|
|
||||||
execFileSync('git', ['merge-base', '--is-ancestor', baseline, 'HEAD'], { stdio: 'pipe' })
|
|
||||||
changes = execFileSync('git', ['diff', '--name-only', '-z', baseline, 'HEAD'], { encoding: 'utf8' }).split('\0').filter(Boolean)
|
|
||||||
} catch { baseline = '' }
|
|
||||||
}
|
|
||||||
const sources = modules.length === 1 && modules[0] === moduleName
|
|
||||||
? ['main', 'test'].flatMap(set => walk(resolve(root, moduleName, 'src', set, 'java')))
|
|
||||||
.map(path => {
|
|
||||||
const key = relative(root, path).replaceAll('\\', '/')
|
|
||||||
const entry = { path: key, source: readFileSync(path, 'utf8') }
|
|
||||||
if (baseline && changes.includes(key)) {
|
|
||||||
try { entry.previousSource = execFileSync('git', ['show', `${baseline}:${key}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) }
|
|
||||||
catch { entry.previousSource = '' }
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}) : []
|
|
||||||
const plan = { ...planTests({ modules, mode: values.mode || 'AUTO', baseline, changes, sources }), startedAt: Date.now() }
|
|
||||||
writeFileSync(planFile, JSON.stringify(plan))
|
|
||||||
console.log(`CI test scope: ${plan.scope}; ${plan.reason}`)
|
|
||||||
if (plan.scope === 'AFFECTED') console.log(`Selected ${plan.tests.length}/${plan.totalTests} test classes:\n${plan.tests.join('\n')}`)
|
|
||||||
if (plan.optInTests?.length) console.log(`Explicit environment tests remain opt-in (not counted as passed): ${plan.optInTests.join(',')}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const plan = JSON.parse(readFileSync(planFile, 'utf8'))
|
|
||||||
if (action === 'args') {
|
|
||||||
if (plan.scope === 'AFFECTED') process.stdout.write(`-Dtest=${plan.tests.join(',')} -Dsurefire.failIfNoSpecifiedTests=false -Dci.sql-semantic.skip=true`)
|
|
||||||
} else if (action === 'checks') {
|
|
||||||
for (const check of plan.staticChecks || []) {
|
|
||||||
if (check !== `${moduleName}/scripts/analyze-lastleg-dependencies.test.mjs`) throw new Error('Unsupported static check')
|
|
||||||
execFileSync(process.execPath, ['--test', '--test-concurrency=2', check], { stdio: 'inherit' })
|
|
||||||
}
|
|
||||||
} else if (action === 'verify') {
|
|
||||||
const reports = []
|
|
||||||
if (plan.scope === 'AFFECTED') for (const module of plan.modules) {
|
|
||||||
const directory = resolve(root, module, 'target/surefire-reports')
|
|
||||||
for (const filename of readdirSync(directory)) {
|
|
||||||
if (!/^TEST-.*\.xml$/.test(filename)) continue
|
|
||||||
const path = resolve(directory, filename)
|
|
||||||
if (statSync(path).mtimeMs < plan.startedAt - 2000) continue
|
|
||||||
const xml = readFileSync(path, 'utf8').match(/<testsuite\b([^>]+)>/)?.[1]
|
|
||||||
if (!xml) continue
|
|
||||||
const attrs = Object.fromEntries([...xml.matchAll(/([\w-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]))
|
|
||||||
reports.push({ name: attrs.name || '', ...Object.fromEntries(['tests', 'failures', 'errors', 'skipped'].map(key => [key, Number(attrs[key] || 0)])) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
verifyReports(plan, reports)
|
|
||||||
console.log(`CI test reports verified: ${plan.scope}`)
|
|
||||||
} else throw new Error(`Unknown action: ${action}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
||||||
try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 }
|
|
||||||
}
|
|
||||||
+3
-17
@@ -1,11 +1,5 @@
|
|||||||
# 单测试环境数据库空库初始化
|
# 单测试环境数据库空库初始化
|
||||||
|
|
||||||
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
|
||||||
|
|
||||||
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
|
||||||
|
|
||||||
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
|
|
||||||
|
|
||||||
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
|
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
|
||||||
|
|
||||||
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
|
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
|
||||||
@@ -56,10 +50,6 @@
|
|||||||
|
|
||||||
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
|
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
|
||||||
|
|
||||||
排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
|
|
||||||
|
|
||||||
订舱委/实资料在 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一空库基线同步维护:订舱主表增加条款、包装、品名、唛头和委/实三项货量,关系方增加邮箱、税号;已放舱主表触发器仅允许实际货量及更新审计元数据变化,运输、预报和创建信息冻结。匹配发布单元为 Firstleg 与 Admin;Print 继续使用现有模板。本轮只准备初始化定义,不执行 SQL、不回填测试存量、不提供新旧兼容。已有测试库须另行审阅、授权结构更新,不能重放完整空库基线。
|
|
||||||
|
|
||||||
## 唯一入口与区域边界
|
## 唯一入口与区域边界
|
||||||
|
|
||||||
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
|
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
|
||||||
@@ -68,8 +58,8 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
|||||||
|
|
||||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||||
|---|---|---|---:|
|
|---|---|---|---:|
|
||||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
|
||||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 |
|
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 |
|
||||||
|
|
||||||
|
|
||||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||||
@@ -92,7 +82,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
|||||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||||
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
||||||
|
|
||||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||||
|
|
||||||
## 平台管理员密码
|
## 平台管理员密码
|
||||||
|
|
||||||
@@ -188,7 +178,3 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
|||||||
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
||||||
|
|
||||||
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
|
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
|
||||||
|
|
||||||
## 统一业务单号
|
|
||||||
|
|
||||||
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
-- MySQL 8 可重复执行增量:仓储转运到仓取货与本土卡车接入现有排车。
|
|
||||||
-- 目标数据库固定为 baiyue_lastleg;仅新增运输类型、原单关联及请求账本,不回填或删除业务数据。
|
|
||||||
-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。
|
|
||||||
USE baiyue_lastleg;
|
|
||||||
|
|
||||||
SET @dispatch_transport_column_ddl = IF(
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'task_type'
|
|
||||||
),
|
|
||||||
'SELECT 1',
|
|
||||||
'ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''任务类型:仓库派送、到仓取货、本土卡车'' AFTER dispatch_no'
|
|
||||||
);
|
|
||||||
PREPARE dispatch_transport_column_stmt FROM @dispatch_transport_column_ddl;
|
|
||||||
EXECUTE dispatch_transport_column_stmt;
|
|
||||||
DEALLOCATE PREPARE dispatch_transport_column_stmt;
|
|
||||||
|
|
||||||
-- 旧库兼容:整车主单跨客户,原来单客户排车头字段不再要求填写。
|
|
||||||
-- 如目标库已删除旧字段则跳过;已有客户值保留,不置空、不修改。
|
|
||||||
SET @dispatch_transport_legacy_ddl = IF(
|
|
||||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'customer_id' AND is_nullable = 'NO'),
|
|
||||||
'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NULL COMMENT ''旧客户排车归属,整车主单不使用''',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
|
||||||
EXECUTE dispatch_transport_legacy_stmt;
|
|
||||||
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
|
||||||
|
|
||||||
-- 旧本土卡车结构兼容:新单沿用已有的起终点联系人及pickup_at/delivery_at字段。
|
|
||||||
-- 旧单字段保留原值,仅放宽空值约束,避免新单被已弃用字段阻断。
|
|
||||||
SET @dispatch_transport_legacy_ddl = IF(
|
|
||||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_name' AND is_nullable = 'NO'),
|
|
||||||
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NULL COMMENT ''旧本土卡车联系人,新单使用起终点联系人''',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
|
||||||
EXECUTE dispatch_transport_legacy_stmt;
|
|
||||||
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
|
||||||
|
|
||||||
SET @dispatch_transport_legacy_ddl = IF(
|
|
||||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_info' AND is_nullable = 'NO'),
|
|
||||||
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NULL COMMENT ''旧本土卡车联系方式,新单使用起终点联系方式''',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
|
||||||
EXECUTE dispatch_transport_legacy_stmt;
|
|
||||||
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
|
||||||
|
|
||||||
SET @dispatch_transport_legacy_ddl = IF(
|
|
||||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'requested_date' AND is_nullable = 'NO'),
|
|
||||||
'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NULL COMMENT ''旧本土卡车请求日期,新单使用取货和送货时间''',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
|
||||||
EXECUTE dispatch_transport_legacy_stmt;
|
|
||||||
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
|
||||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
|
||||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
|
||||||
PRIMARY KEY (business_date)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
|
|
||||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
|
||||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
|
||||||
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
|
||||||
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
|
||||||
PRIMARY KEY (dispatch_no),
|
|
||||||
UNIQUE KEY uk_transport_trip (trip_no),
|
|
||||||
UNIQUE KEY uk_transport_source (active_order),
|
|
||||||
KEY idx_transport_order (customer_id, order_no)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
|
|
||||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
|
||||||
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要',
|
|
||||||
PRIMARY KEY (trip_no)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
|
|
||||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
|
||||||
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
|
||||||
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
|
||||||
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
|
||||||
PRIMARY KEY (dispatch_no, request_key)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
|
||||||
|
|
||||||
-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。
|
|
||||||
SET @dispatch_transport_pool_ddl = IF(
|
|
||||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND column_name = 'source_looseboard_order_id'),
|
|
||||||
'SELECT 1',
|
|
||||||
'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''
|
|
||||||
);
|
|
||||||
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
|
|
||||||
EXECUTE dispatch_transport_pool_stmt;
|
|
||||||
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
|
|
||||||
|
|
||||||
SET @dispatch_transport_pool_ddl = IF(
|
|
||||||
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND index_name = 'idx_booking_looseboard_source'),
|
|
||||||
'SELECT 1',
|
|
||||||
'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id, source_looseboard_order_id, status)'
|
|
||||||
);
|
|
||||||
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
|
|
||||||
EXECUTE dispatch_transport_pool_stmt;
|
|
||||||
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- 仓储取货与本土卡车接入现有排车;保留旧排车用途默认值。
|
|
||||||
ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车' AFTER dispatch_no;
|
|
||||||
CREATE TABLE ll_dispatch_truck_task (
|
|
||||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
|
||||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
|
||||||
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
|
||||||
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
|
||||||
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
|
|
||||||
KEY idx_transport_order(customer_id,order_no)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
|
||||||
CREATE TABLE ll_dispatch_transport_create (
|
|
||||||
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
|
|
||||||
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
|
||||||
CREATE TABLE ll_dispatch_transport_command (
|
|
||||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
|
||||||
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
|
||||||
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
|
||||||
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
|
||||||
PRIMARY KEY(dispatch_no,request_key)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- Region: OVERSEAS
|
|
||||||
-- Schema: baiyue_lastleg
|
|
||||||
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
|
|
||||||
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
|
|
||||||
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
|
|
||||||
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
|
|
||||||
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
|
||||||
EXECUTE looseboard_demand_stmt;
|
|
||||||
DEALLOCATE PREPARE looseboard_demand_stmt;
|
|
||||||
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
|
|
||||||
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
|
||||||
EXECUTE looseboard_demand_stmt;
|
|
||||||
DEALLOCATE PREPARE looseboard_demand_stmt;
|
|
||||||
@@ -1484,6 +1484,7 @@ CREATE TABLE fl_container_booking (
|
|||||||
KEY idx_container_booking_voyage (voyage_no,updated_at),
|
KEY idx_container_booking_voyage (voyage_no,updated_at),
|
||||||
KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type),
|
KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type),
|
||||||
CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))),
|
CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))),
|
||||||
|
CONSTRAINT chk_container_booking_iso6346 CHECK (((container_no is null) or regexp_like(container_no,_utf8mb4'^[A-Z]{3}[UJZ][0-9]{7}$'))),
|
||||||
CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))),
|
CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))),
|
||||||
CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))),
|
CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))),
|
||||||
CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))),
|
CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))),
|
||||||
@@ -1980,11 +1981,11 @@ CREATE TABLE fl_customs_clearance_result (
|
|||||||
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
|
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
|
||||||
|
|
||||||
CREATE TABLE fl_document_sequence (
|
CREATE TABLE fl_document_sequence (
|
||||||
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
|
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
|
||||||
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
|
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE fl_fee_item (
|
CREATE TABLE fl_fee_item (
|
||||||
id bigint unsigned NOT NULL COMMENT '费用项目ID',
|
id bigint unsigned NOT NULL COMMENT '费用项目ID',
|
||||||
@@ -3441,7 +3442,7 @@ CREATE TABLE fl_mojia_measurement_receipt (
|
|||||||
CREATE TABLE fl_ocean_booking (
|
CREATE TABLE fl_ocean_booking (
|
||||||
id bigint unsigned NOT NULL COMMENT '海运订舱ID',
|
id bigint unsigned NOT NULL COMMENT '海运订舱ID',
|
||||||
booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号',
|
booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号',
|
||||||
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
|
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
|
||||||
customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键',
|
customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键',
|
||||||
customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
|
customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
|
||||||
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
|
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||||
@@ -3464,17 +3465,6 @@ CREATE TABLE fl_ocean_booking (
|
|||||||
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
|
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
|
||||||
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间',
|
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间',
|
||||||
remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注',
|
remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注',
|
||||||
freight_term VARCHAR(64) NULL COMMENT '运费条款',
|
|
||||||
service_term VARCHAR(64) NULL COMMENT '运输条款',
|
|
||||||
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
|
|
||||||
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
|
|
||||||
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
|
|
||||||
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
|
|
||||||
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
|
|
||||||
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
|
|
||||||
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
|
|
||||||
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
|
|
||||||
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
|
|
||||||
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
|
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
|
||||||
created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照',
|
created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照',
|
||||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
||||||
@@ -3489,14 +3479,6 @@ CREATE TABLE fl_ocean_booking (
|
|||||||
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
|
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
|
||||||
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
|
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
|
||||||
CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)),
|
CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)),
|
||||||
CONSTRAINT chk_ocean_booking_cargo CHECK (
|
|
||||||
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
|
|
||||||
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
|
|
||||||
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
|
|
||||||
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
|
|
||||||
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
|
|
||||||
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
|
|
||||||
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
|
|
||||||
CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))),
|
CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))),
|
||||||
CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
|
CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||||
@@ -3583,8 +3565,6 @@ CREATE TABLE fl_ocean_booking_party (
|
|||||||
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
|
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
|
||||||
contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照',
|
contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照',
|
||||||
contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照',
|
contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照',
|
||||||
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
|
|
||||||
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
||||||
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE,
|
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE,
|
||||||
@@ -4457,6 +4437,20 @@ CREATE TABLE fl_inbound_split_item (
|
|||||||
|
|
||||||
SET SESSION foreign_key_checks = 1;
|
SET SESSION foreign_key_checks = 1;
|
||||||
|
|
||||||
|
-- 客户独立确认计费重,不回填既有费用确认。
|
||||||
|
CREATE TABLE fl_chargeable_weight_confirmation (
|
||||||
|
id BIGINT NOT NULL COMMENT '确认记录ID',
|
||||||
|
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
|
||||||
|
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
|
||||||
|
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
|
||||||
|
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
|
||||||
|
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
|
||||||
|
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
|
||||||
|
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
|
||||||
|
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
|
||||||
|
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
|
||||||
|
|
||||||
-- 服务 schema:baiyue_file
|
-- 服务 schema:baiyue_file
|
||||||
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
USE baiyue_file;
|
USE baiyue_file;
|
||||||
@@ -4876,7 +4870,7 @@ CREATE TABLE fin_document_sequence (
|
|||||||
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
|
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE fin_fee_source (
|
CREATE TABLE fin_fee_source (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
@@ -5003,7 +4997,7 @@ CREATE TABLE fin_receipt_bill (
|
|||||||
CREATE TABLE fin_recharge_request (
|
CREATE TABLE fin_recharge_request (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
||||||
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
|
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
|
||||||
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
|
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
|
||||||
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
|
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
|
||||||
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
|
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
|
||||||
@@ -5254,7 +5248,7 @@ CREATE TABLE fin_wallet_hold (
|
|||||||
CREATE TABLE fin_wallet_ledger (
|
CREATE TABLE fin_wallet_ledger (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
||||||
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
|
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
||||||
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||||
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||||
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
|
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||||
@@ -6046,48 +6040,9 @@ CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_book
|
|||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
|
|
||||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN
|
||||||
BEGIN
|
IF OLD.status = 'RELEASED' THEN
|
||||||
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
|
||||||
IF OLD.status = 'RELEASED' AND NOT (
|
|
||||||
(NEW.id <=> OLD.id)
|
|
||||||
AND (NEW.booking_no <=> OLD.booking_no)
|
|
||||||
AND (NEW.status <=> OLD.status)
|
|
||||||
AND (NEW.customer_id <=> OLD.customer_id)
|
|
||||||
AND (NEW.customer_name <=> OLD.customer_name)
|
|
||||||
AND (NEW.channel_code <=> OLD.channel_code)
|
|
||||||
AND (NEW.channel_name <=> OLD.channel_name)
|
|
||||||
AND (NEW.warehouse_id <=> OLD.warehouse_id)
|
|
||||||
AND (NEW.warehouse_name <=> OLD.warehouse_name)
|
|
||||||
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
|
|
||||||
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
|
|
||||||
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
|
|
||||||
AND (NEW.carrier_code <=> OLD.carrier_code)
|
|
||||||
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
|
|
||||||
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
|
|
||||||
AND (NEW.vessel_name <=> OLD.vessel_name)
|
|
||||||
AND (NEW.voyage_no <=> OLD.voyage_no)
|
|
||||||
AND (NEW.departure_port_code <=> OLD.departure_port_code)
|
|
||||||
AND (NEW.departure_port_name <=> OLD.departure_port_name)
|
|
||||||
AND (NEW.destination_port_code <=> OLD.destination_port_code)
|
|
||||||
AND (NEW.destination_port_name <=> OLD.destination_port_name)
|
|
||||||
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
|
|
||||||
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
|
|
||||||
AND (NEW.remark <=> OLD.remark)
|
|
||||||
AND (NEW.freight_term <=> OLD.freight_term)
|
|
||||||
AND (NEW.service_term <=> OLD.service_term)
|
|
||||||
AND (NEW.package_unit <=> OLD.package_unit)
|
|
||||||
AND (NEW.cargo_description <=> OLD.cargo_description)
|
|
||||||
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
|
|
||||||
AND (NEW.declared_package_count <=> OLD.declared_package_count)
|
|
||||||
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
|
|
||||||
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
|
|
||||||
AND (NEW.created_by <=> OLD.created_by)
|
|
||||||
AND (NEW.created_by_name <=> OLD.created_by_name)
|
|
||||||
AND (NEW.created_at <=> OLD.created_at)
|
|
||||||
AND NEW.version = OLD.version + 1
|
|
||||||
) THEN
|
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
|
|
||||||
END IF;
|
END IF;
|
||||||
IF OLD.status <> NEW.status AND NOT (
|
IF OLD.status <> NEW.status AND NOT (
|
||||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||||
@@ -8442,20 +8397,4 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
|
|||||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
|
||||||
|
|
||||||
USE baiyue_order;
|
SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables;
|
||||||
CREATE TABLE ord_document_sequence (
|
|
||||||
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
|
||||||
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
|
||||||
PRIMARY KEY (sequence_key),
|
|
||||||
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
|
||||||
|
|
||||||
USE baiyue_warehouse;
|
|
||||||
CREATE TABLE wh_document_sequence (
|
|
||||||
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
|
||||||
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
|
||||||
PRIMARY KEY (sequence_key),
|
|
||||||
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# 排车运输接入数据库变更汇总
|
|
||||||
|
|
||||||
执行文件:[lastleg-dispatch-transport-tasks.sql](lastleg-dispatch-transport-tasks.sql)。目标为 **MySQL 8 / baiyue_lastleg**,文件内已有 `USE baiyue_lastleg`。可整份重复执行;按 information_schema 判断字段及索引,按 IF NOT EXISTS 创建表。已在本机既有业务数据环境连续执行两次通过;未在远端环境执行。
|
|
||||||
|
|
||||||
| 对象 | 新增或调整 | 用途 |
|
|
||||||
|---|---|---|
|
|
||||||
| ll_dispatch_order.task_type | 新增 VARCHAR(32) NOT NULL DEFAULT DELIVERY | 区分仓库派送、到仓取货、本土卡车;旧单保留派送类型 |
|
|
||||||
| ll_dispatch_truck_task | 新表;dispatch_no、trip_no、customer_id、order_no、active_order | 真实排车关联原单;运输记录唯一、有效原单唯一;取消释放有效键,完成保留 |
|
|
||||||
| ll_dispatch_transport_create | 新表;trip_no、request_hash | 原安排请求摘要,避免重复建单及同键更换原单 |
|
|
||||||
| ll_dispatch_transport_command | 新表;dispatch_no、request_key、request_hash、evidence_json、created_at | 执行命令幂等、已确认完成凭证持久化 |
|
|
||||||
| ll_dispatch_daily_sequence | 既有正式日流水表,缺失时补建 | 统一 PC 日期与四位流水;不修改历史单号 |
|
|
||||||
| ll_dispatch_order.customer_id | 仅旧库存在且为 NOT NULL 时改为允许 NULL | 兼容整车跨客户主单;保留原值,不回填或置空 |
|
|
||||||
| ll_us_truck_order.contact_name / contact_info / requested_date | 仅旧库仍为 NOT NULL 时改为允许 NULL | 兼容现有原单起终点联系人及取送时间模型,保留历史字段和数据 |
|
|
||||||
| ll_fleet_external_booking.source_looseboard_order_id | 缺失时补 BIGINT NULL | 当前承运占用查询依赖,等同 V33;不创建散板业务 |
|
|
||||||
| ll_fleet_external_booking.idx_booking_looseboard_source | 缺失时补客户、原散板ID、状态联合索引 | 原单承运占用查询 |
|
|
||||||
|
|
||||||
所有新增表、字段均有中文注释。SQL不创建账号、不改角色权限、不删除表或记录、不改库存及历史单号,也不回填旧任务。
|
|
||||||
|
|
||||||
前置:目标库已具备当前整车排车、FleetTrip、仓储转运取货及本土卡车业务结构;本文件是本次运输接入增量,不替代这些模块的完整升级。执行完成后配套发布 Lastleg 和 Admin。迁移系统记录为 V32,承运占用依赖结构为 V33;直接交付执行时使用本份汇总 SQL 即可,不再重复手动执行两个版本文件。
|
|
||||||
|
|
||||||
本机验收中发现的旧字段约束与缺少日流水表已一并纳入。业务数据由正式 Gateway HTTP 创建,结构验证未写业务SQL。
|
|
||||||
@@ -416,8 +416,6 @@ CREATE TABLE ll_dispatch_item (
|
|||||||
CREATE TABLE ll_dispatch_order (
|
CREATE TABLE ll_dispatch_order (
|
||||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
||||||
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
|
|
||||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位',
|
|
||||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||||
@@ -430,7 +428,6 @@ CREATE TABLE ll_dispatch_order (
|
|||||||
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
|
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
|
||||||
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
|
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
|
||||||
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
|
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
|
||||||
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
|
|
||||||
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
|
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
|
||||||
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
|
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
|
||||||
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
|
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
|
||||||
@@ -1659,6 +1656,12 @@ CREATE TABLE ll_monthly_exchange_rate_history (
|
|||||||
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
|
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||||
|
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||||
|
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||||
|
PRIMARY KEY(business_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||||
|
|
||||||
CREATE TABLE ll_order (
|
CREATE TABLE ll_order (
|
||||||
id bigint NOT NULL COMMENT '雪花 ID',
|
id bigint NOT NULL COMMENT '雪花 ID',
|
||||||
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
|
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
|
||||||
@@ -1862,11 +1865,11 @@ CREATE TABLE ll_order_manual_node_history (
|
|||||||
KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at)
|
KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史';
|
||||||
|
|
||||||
CREATE TABLE ll_dispatch_daily_sequence (
|
CREATE TABLE ll_order_no_daily_sequence (
|
||||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
business_date date NOT NULL COMMENT '业务日期(Asia/Shanghai)',
|
||||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
last_sequence int NOT NULL COMMENT '当日已分配尾程单流水号',
|
||||||
PRIMARY KEY(business_date)
|
PRIMARY KEY (business_date)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
|
||||||
|
|
||||||
CREATE TABLE ll_order_operation_log (
|
CREATE TABLE ll_order_operation_log (
|
||||||
id bigint NOT NULL COMMENT '操作记录主键',
|
id bigint NOT NULL COMMENT '操作记录主键',
|
||||||
@@ -2074,7 +2077,6 @@ CREATE TABLE ll_overseas_procurement_fee (
|
|||||||
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
|
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
|
||||||
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
|
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
|
||||||
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
|
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
|
||||||
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
|
|
||||||
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
|
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
|
||||||
id bigint NOT NULL COMMENT '主键ID',
|
id bigint NOT NULL COMMENT '主键ID',
|
||||||
order_id bigint NOT NULL COMMENT '采购订单ID',
|
order_id bigint NOT NULL COMMENT '采购订单ID',
|
||||||
@@ -2096,7 +2098,6 @@ CREATE TABLE ll_overseas_procurement_fee (
|
|||||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
|
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
|
||||||
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
|
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
|
||||||
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
|
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
|
||||||
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
|
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
|
||||||
@@ -2367,6 +2368,14 @@ CREATE TABLE ll_pda_stocktake_task (
|
|||||||
KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at)
|
KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务';
|
||||||
|
|
||||||
|
CREATE TABLE ll_procurement_daily_sequence (
|
||||||
|
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
|
||||||
|
business_date date NOT NULL COMMENT '北京时间业务日期',
|
||||||
|
sequence_value int NOT NULL COMMENT '客户当日已分配最大序号',
|
||||||
|
PRIMARY KEY (customer_id,business_date),
|
||||||
|
CONSTRAINT chk_ll_procurement_sequence CHECK ((sequence_value between 1 and 9999))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列';
|
||||||
|
|
||||||
CREATE TABLE ll_receiving_container_operation (
|
CREATE TABLE ll_receiving_container_operation (
|
||||||
id bigint NOT NULL COMMENT '柜号收货操作ID',
|
id bigint NOT NULL COMMENT '柜号收货操作ID',
|
||||||
session_id bigint NOT NULL COMMENT '柜号签收会话ID',
|
session_id bigint NOT NULL COMMENT '柜号签收会话ID',
|
||||||
@@ -3203,7 +3212,7 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
|
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
|
||||||
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
|
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
|
||||||
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
|
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
|
||||||
incoming_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '正式来货单业务编号',
|
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号',
|
||||||
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
|
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
|
||||||
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
|
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
|
||||||
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
|
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
|
||||||
@@ -3213,7 +3222,6 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
||||||
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
||||||
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
||||||
source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源',
|
|
||||||
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
||||||
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
||||||
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
||||||
@@ -3228,7 +3236,6 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
|
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
|
||||||
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
|
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
|
||||||
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
|
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
|
||||||
KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status),
|
|
||||||
PRIMARY KEY(id),
|
PRIMARY KEY(id),
|
||||||
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
||||||
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
||||||
@@ -4821,6 +4828,12 @@ CREATE TABLE ll_appointment_work_history (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
|
||||||
|
|
||||||
-- 海外业务短单号分配时钟。
|
-- 海外业务短单号分配时钟。
|
||||||
|
CREATE TABLE ll_compact_number_clock (
|
||||||
|
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
||||||
|
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
|
||||||
|
PRIMARY KEY (number_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
|
||||||
|
|
||||||
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
|
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
|
||||||
USE baiyue_lastleg;
|
USE baiyue_lastleg;
|
||||||
CREATE TABLE ll_fleet_pickup_plan (
|
CREATE TABLE ll_fleet_pickup_plan (
|
||||||
@@ -4875,8 +4888,7 @@ CREATE TABLE ll_fleet_pickup_history (
|
|||||||
KEY idx_pickup_history_trip(trip_no,id)
|
KEY idx_pickup_history_trip(trip_no,id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
||||||
|
|
||||||
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
|
||||||
CREATE TABLE ll_fleet_owned_business_ownership (
|
|
||||||
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
|
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
|
||||||
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
|
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
|
||||||
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
|
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
|
||||||
@@ -4894,7 +4906,7 @@ CREATE TABLE ll_fleet_owned_business_ownership (
|
|||||||
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
|
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
|
||||||
|
|
||||||
CREATE TABLE ll_fleet_owned_business_record (
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
|
||||||
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
|
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
|
||||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
|
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
|
||||||
@@ -4919,7 +4931,7 @@ CREATE TABLE ll_fleet_owned_business_record (
|
|||||||
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
|
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
|
||||||
|
|
||||||
CREATE TABLE ll_fleet_owned_business_command (
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
|
||||||
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
|
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
|
||||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
|
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
|
||||||
@@ -4940,7 +4952,7 @@ CREATE TABLE ll_fleet_owned_business_command (
|
|||||||
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
|
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
|
||||||
|
|
||||||
CREATE TABLE ll_fleet_owned_business_history (
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||||
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
|
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
|
||||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
|
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
|
||||||
@@ -4955,36 +4967,4 @@ CREATE TABLE ll_fleet_owned_business_history (
|
|||||||
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
|
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
|
||||||
|
|
||||||
-- 运输原单接入现有排车,不产生仓内库存事实。
|
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 233 AS initialized_tables;
|
||||||
USE baiyue_lastleg;
|
|
||||||
CREATE TABLE ll_dispatch_truck_task (
|
|
||||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
|
||||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
|
||||||
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
|
||||||
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
|
||||||
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
|
|
||||||
KEY idx_transport_order(customer_id,order_no)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
|
||||||
CREATE TABLE ll_dispatch_transport_create (
|
|
||||||
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
|
|
||||||
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
|
||||||
CREATE TABLE ll_dispatch_transport_command (
|
|
||||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
|
||||||
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
|
||||||
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
|
||||||
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
|
||||||
PRIMARY KEY(dispatch_no,request_key)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
|
||||||
|
|
||||||
USE baiyue_lastleg;
|
|
||||||
CREATE TABLE ll_document_sequence (
|
|
||||||
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
|
||||||
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
|
||||||
PRIMARY KEY (sequence_key),
|
|
||||||
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ enum BusinessFileCategory {
|
|||||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
|
||||||
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
|
||||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
@@ -80,9 +78,9 @@ enum BusinessFileCategory {
|
|||||||
&& tenantId != 0) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||||
}
|
}
|
||||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE)
|
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
||||||
&& tenantId != 0) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
||||||
}
|
}
|
||||||
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ info:
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: >-
|
description: >-
|
||||||
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
||||||
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||||
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
|
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
|
||||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
|
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
|
||||||
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
||||||
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
||||||
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
||||||
@@ -126,7 +126,7 @@ components:
|
|||||||
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
||||||
DispatchEvidenceType:
|
DispatchEvidenceType:
|
||||||
type: string
|
type: string
|
||||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE]
|
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
|
||||||
MetadataEnvelope:
|
MetadataEnvelope:
|
||||||
type: object
|
type: object
|
||||||
required: [code, data]
|
required: [code, data]
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ import static org.mockito.Mockito.*;
|
|||||||
|
|
||||||
class DispatchEvidenceFileTest {
|
class DispatchEvidenceFileTest {
|
||||||
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
||||||
private static final String EXCEPTION = "OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE";
|
|
||||||
private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE";
|
|
||||||
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
||||||
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
||||||
@TempDir Path root;
|
@TempDir Path root;
|
||||||
@@ -42,9 +40,9 @@ class DispatchEvidenceFileTest {
|
|||||||
FileObjectEntity.class);
|
FileObjectEntity.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||||
var files = mock(FileObjectMapper.class);
|
var files = mock(FileObjectMapper.class);
|
||||||
var storage = mock(BusinessFileStorage.class);
|
var storage = mock(BusinessFileStorage.class);
|
||||||
@@ -66,19 +64,6 @@ class DispatchEvidenceFileTest {
|
|||||||
verifyNoInteractions(files, storage);
|
verifyNoInteractions(files, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 司机照片与异常图片使用独立平台类别,拒绝PDF和伪造图片。 */
|
|
||||||
@ParameterizedTest @ValueSource(strings = {PHOTO, EXCEPTION})
|
|
||||||
void photoAcceptsOnlyRealImages(String type) throws Exception {
|
|
||||||
var storage=storage();var category=BusinessFileCategory.require(type);
|
|
||||||
for(var format:new String[]{"jpeg","png"}) {
|
|
||||||
var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format));
|
|
||||||
var saved=storage.store(0,"71",upload,category);
|
|
||||||
assertThat(storage.read(saved.objectKey())).isEqualTo(upload.getBytes());
|
|
||||||
}
|
|
||||||
assertThatThrownBy(()->storage.store(0,"71",pdf(),category)).isInstanceOf(BizException.class);
|
|
||||||
assertThatThrownBy(()->storage.store(0,"71",new MockMultipartFile("file","driver.png","image/png",pdf().getBytes()),category)).isInstanceOf(BizException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
|
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||||
@@ -164,7 +149,7 @@ class DispatchEvidenceFileTest {
|
|||||||
|
|
||||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||||
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
||||||
var files = mock(FileObjectMapper.class);
|
var files = mock(FileObjectMapper.class);
|
||||||
var storage = mock(BusinessFileStorage.class);
|
var storage = mock(BusinessFileStorage.class);
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@
|
|||||||
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
|
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
|
||||||
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
||||||
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
||||||
- 财务单据编号遵循 `../../docs/业务单据编号契约.md`,由本服务 Repository/Mapper 按前缀与北京时间日期原子分配;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||||
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
||||||
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
||||||
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
|
|||||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
||||||
String reviewStatus, String sourceStatus,
|
String reviewStatus, String sourceStatus,
|
||||||
boolean statementGenerated) {}
|
boolean statementGenerated) {}
|
||||||
public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
|
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
||||||
BigDecimal amount, String method, String referenceNo,
|
BigDecimal amount, String method, String referenceNo,
|
||||||
List<String> voucherFileIds, long createdBy, String createdByName,
|
List<String> voucherFileIds, long createdBy, String createdByName,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package com.baiyue.finance.mapper;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Options;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
|
|
||||||
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
|
|
||||||
@Mapper
|
|
||||||
public interface DocumentSequenceMapper {
|
|
||||||
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
|
|
||||||
@Insert("INSERT INTO fin_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
|
|
||||||
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
|
|
||||||
int allocate(@Param("key") String key);
|
|
||||||
|
|
||||||
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
|
|
||||||
@Select("SELECT LAST_INSERT_ID()")
|
|
||||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
|
||||||
Long allocatedValue();
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package com.baiyue.finance.mapper;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
|
|
||||||
@Mapper
|
|
||||||
public interface FinanceFeeSourceReadMapper {
|
|
||||||
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))";
|
|
||||||
String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
|
|
||||||
String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
|
|
||||||
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
|
|
||||||
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
|
|
||||||
" AND f.status<>'SUPERSEDED' AND NOT EXISTS(SELECT 1 FROM fin_fee_source newer" +
|
|
||||||
" WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type" +
|
|
||||||
" AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)" +
|
|
||||||
"</if>" +
|
|
||||||
"<if test='customerId != null'> AND f.customer_id=#{customerId}</if>" +
|
|
||||||
"<if test='direction != null'> AND f.direction=#{direction}</if>" +
|
|
||||||
"<if test='reviewStatus != null'> AND f.review_status=#{reviewStatus}</if>" +
|
|
||||||
"<if test='status != null'> AND " + STATUS + "=#{status}</if>" +
|
|
||||||
"<if test='statementGenerated != null'> AND " + GENERATED + "=#{statementGenerated}</if>" +
|
|
||||||
"<if test='dateFrom != null'> AND " + BUSINESS_DATE + ">=#{dateFrom}</if>" +
|
|
||||||
"<if test='dateTo != null'> AND " + BUSINESS_DATE + "<=#{dateTo}</if>" +
|
|
||||||
"<if test='keyword != null'> AND (f.source_no LIKE CONCAT('%',#{keyword},'%')" +
|
|
||||||
" OR f.fee_item_name LIKE CONCAT('%',#{keyword},'%')" +
|
|
||||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',#{keyword},'%')" +
|
|
||||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',#{keyword},'%')" +
|
|
||||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',#{keyword},'%')" +
|
|
||||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',#{keyword},'%'))</if>";
|
|
||||||
|
|
||||||
/** 筛选和计数共用当前版本条件,不能先分页再去重。 */
|
|
||||||
@Select("<script>SELECT COUNT(*)" + FILTER + "</script>")
|
|
||||||
long count(Map<String, Object> filters);
|
|
||||||
|
|
||||||
/** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */
|
|
||||||
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS + " effective_status" + FILTER +
|
|
||||||
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
|
||||||
List<Map<String, Object>> list(Map<String, Object> filters);
|
|
||||||
|
|
||||||
String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" +
|
|
||||||
" AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" +
|
|
||||||
" AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" +
|
|
||||||
" AND f.source_fee_version<=anchor.source_fee_version";
|
|
||||||
|
|
||||||
/** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */
|
|
||||||
@Select("<script>SELECT f.id,f.source_fee_version,f.amount,f.currency_code,f.created_at" + LINEAGE +
|
|
||||||
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId}" +
|
|
||||||
" AND f.source_system IN ('FIRSTLEG','LASTLEG') ORDER BY f.source_fee_version,f.id</script>")
|
|
||||||
List<Map<String, Object>> versions(@Param("customerId") long customerId, @Param("id") long id);
|
|
||||||
|
|
||||||
/** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */
|
|
||||||
@Select("<script>SELECT a.id,a.created_at,a.action,a.operator_name_snapshot,a.reason,a.before_data,a.after_data" + LINEAGE +
|
|
||||||
" JOIN fin_audit_log a ON a.tenant_id=f.customer_id AND a.resource_type='FIN_FEE_SOURCE' AND a.resource_id=f.id" +
|
|
||||||
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId} ORDER BY a.created_at DESC,a.id DESC</script>")
|
|
||||||
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("id") long id);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.baiyue.finance.mapper;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */
|
|
||||||
@Mapper
|
|
||||||
public interface FinanceSettlementDetailMapper {
|
|
||||||
String SUMMARY_SELECT =
|
|
||||||
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
|
||||||
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
|
||||||
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
|
||||||
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
|
||||||
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
|
||||||
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
|
||||||
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
|
||||||
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
|
||||||
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
|
||||||
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
|
||||||
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
|
||||||
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
|
||||||
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
|
||||||
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
|
||||||
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
|
||||||
/** 读取平台结算主单及来源摘要。 */
|
|
||||||
@Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}")
|
|
||||||
List<Map<String, Object>> summary(@Param("settlementId") long settlementId);
|
|
||||||
/** 读取该客户该结算的费用明细。 */
|
|
||||||
@Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.id")
|
|
||||||
List<Map<String, Object>> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
|
||||||
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
|
||||||
@Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id")
|
|
||||||
List<Map<String, Object>> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
|
||||||
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
|
||||||
@Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id")
|
|
||||||
List<Map<String, Object>> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId);
|
|
||||||
/** 读取该客户结算修订历史。 */
|
|
||||||
@Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id")
|
|
||||||
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.baiyue.finance.repository;
|
|
||||||
|
|
||||||
import com.baiyue.finance.mapper.DocumentSequenceMapper;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
|
|
||||||
@Repository
|
|
||||||
public class DocumentSequenceRepository {
|
|
||||||
private final DocumentSequenceMapper mapper;
|
|
||||||
|
|
||||||
/** 只访问所属服务的编号表。 */
|
|
||||||
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
|
|
||||||
|
|
||||||
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
public long next(String key) {
|
|
||||||
mapper.allocate(key);
|
|
||||||
Long value = mapper.allocatedValue();
|
|
||||||
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-145
@@ -1,145 +0,0 @@
|
|||||||
package com.baiyue.finance.repository;
|
|
||||||
|
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
|
||||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
|
||||||
import com.baiyue.finance.service.FinanceEventException;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** 费用当前投影及操作历史;版本记录只读,不覆盖账单引用的不可变快照。 */
|
|
||||||
@Repository
|
|
||||||
public class FinanceFeeSourceReadRepository {
|
|
||||||
private final FinanceFeeSourceReadMapper mapper;
|
|
||||||
private final ObjectMapper json;
|
|
||||||
|
|
||||||
/** 注入正式持久化入口与费用快照解析器。 */
|
|
||||||
public FinanceFeeSourceReadRepository(FinanceFeeSourceReadMapper mapper, ObjectMapper json) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.json = json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 组装统一查询参数,让计数和分页使用相同来源身份与筛选条件。 */
|
|
||||||
public Map<String, Object> filters(Long customerId, String direction, String status, String reviewStatus,
|
|
||||||
Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) {
|
|
||||||
Map<String, Object> values = new HashMap<>();
|
|
||||||
values.put("customerId", customerId); values.put("direction", direction); values.put("status", status);
|
|
||||||
values.put("reviewStatus", reviewStatus); values.put("statementGenerated", generated);
|
|
||||||
values.put("dateFrom", from); values.put("dateTo", to); values.put("keyword", keyword);
|
|
||||||
values.put("limit", size); values.put("offset", (page - 1) * size);
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 计数只包含所筛选的当前费用,历史状态专项查询除外。 */
|
|
||||||
public long count(Map<String, Object> filters) { return mapper.count(filters); }
|
|
||||||
|
|
||||||
/** 当前费用映射沿用原币、数量、客户等正式字段。 */
|
|
||||||
public List<FeeSourceSummary> list(Map<String, Object> filters) {
|
|
||||||
return mapper.list(filters).stream().map(this::summary).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
|
|
||||||
public List<OperationLog> logs(long customerId, long id) {
|
|
||||||
List<OperationLog> result = new ArrayList<>();
|
|
||||||
Map<String, Object> previous = null;
|
|
||||||
for (Map<String, Object> raw : mapper.versions(customerId, id)) {
|
|
||||||
Map<String, Object> row = row(raw);
|
|
||||||
String version = "来源版本 " + row.get("source_fee_version");
|
|
||||||
String amount = amount(row) + " " + row.get("currency_code");
|
|
||||||
// 同步时间与原业务操作者不能混用,原业务审批仍以来源系统记录为准。
|
|
||||||
result.add(new OperationLog(time(row.get("created_at")),
|
|
||||||
previous == null ? "FEE_SOURCE_PUBLISHED" : "FEE_SOURCE_VERSION_ADJUST",
|
|
||||||
"系统同步", "SUCCESS", previous == null ? version + ";金额 " + amount
|
|
||||||
: "来源版本 " + previous.get("source_fee_version") + " → " + row.get("source_fee_version")
|
|
||||||
+ ";金额 " + amount(previous) + " → " + amount));
|
|
||||||
previous = row;
|
|
||||||
}
|
|
||||||
// 金额差分仍按版本升序计算;展示先反转版本,同毫秒时稳定排序保留新版本在前。
|
|
||||||
java.util.Collections.reverse(result);
|
|
||||||
for (Map<String, Object> raw : mapper.audits(customerId, id)) {
|
|
||||||
Map<String, Object> row = row(raw);
|
|
||||||
String remark = text(row.get("reason"));
|
|
||||||
JsonNode before = parse(row.get("before_data"));
|
|
||||||
JsonNode after = parse(row.get("after_data"));
|
|
||||||
if (before.hasNonNull("amount") && after.hasNonNull("amount")
|
|
||||||
&& before.path("amount").decimalValue().compareTo(after.path("amount").decimalValue()) != 0) {
|
|
||||||
remark = (remark == null ? "" : remark + ";") + "金额 " + before.path("amount").asText()
|
|
||||||
+ " → " + after.path("amount").asText() + " " + after.path("currency").asText("CNY");
|
|
||||||
}
|
|
||||||
result.add(new OperationLog(time(row.get("created_at")), text(row.get("action")),
|
|
||||||
text(row.get("operator_name_snapshot")), "SUCCESS", remark));
|
|
||||||
}
|
|
||||||
// 时间排序保持同毫秒版本的新到旧顺序,以及 Mapper 返回的审计 ID 倒序。
|
|
||||||
result.sort(Comparator.comparing(OperationLog::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
|
|
||||||
return List.copyOf(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将持久化快照转换为费用列表 DTO,不从其他客户或来源补齐业务事实。 */
|
|
||||||
private FeeSourceSummary summary(Map<String, Object> raw) {
|
|
||||||
Map<String, Object> row = row(raw);
|
|
||||||
JsonNode snapshot = parse(row.get("source_snapshot"));
|
|
||||||
BigDecimal amount = decimal(row.get("amount"));
|
|
||||||
BigDecimal originalAmount = first(decimal(text(snapshot, "originalAmount")), amount);
|
|
||||||
BigDecimal quantity = decimal(text(snapshot, "chargeableQuantity"));
|
|
||||||
BigDecimal unitPrice = first(decimal(text(snapshot, "unitPrice")), quantity != null && quantity.signum() > 0
|
|
||||||
? originalAmount.divide(quantity, 6, RoundingMode.HALF_UP) : null);
|
|
||||||
return new FeeSourceSummary(number(row.get("id")), text(row.get("source_system")), text(row.get("source_type")),
|
|
||||||
first(text(row.get("source_no")), text(snapshot, "sourceBusinessId")), number(row.get("source_fee_id")),
|
|
||||||
((Number) row.get("source_fee_version")).intValue(), text(row.get("direction")), number(row.get("customer_id")),
|
|
||||||
text(snapshot, "customerName"), positiveLong(text(snapshot, "supplierId")), text(snapshot, "supplierName"),
|
|
||||||
nullableLong(row.get("order_id")), nullableLong(row.get("inbound_id")), text(snapshot, "inboundCode"),
|
|
||||||
nullableLong(row.get("container_id")), text(snapshot, "containerNo"),
|
|
||||||
first(date(text(snapshot, "businessDate")), LocalDate.of(1970, 1, 1)), text(row.get("fee_item_name")),
|
|
||||||
quantity, text(snapshot, "quantityUnit"), unitPrice, date(text(snapshot, "periodStart")), date(text(snapshot, "periodEnd")),
|
|
||||||
amount, text(row.get("currency_code")), first(text(snapshot, "originalCurrency"), text(row.get("currency_code"))),
|
|
||||||
originalAmount, first(decimal(text(snapshot, "exchangeRate")), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
|
||||||
first(text(row.get("review_status")), "DRAFT"), text(row.get("effective_status")),
|
|
||||||
Boolean.TRUE.equals(row.get("statement_generated")) || row.get("statement_generated") instanceof Number value && value.intValue() != 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保留不同数据库驱动的列名大小写读取语义。 */
|
|
||||||
private static Map<String, Object> row(Map<String, Object> source) {
|
|
||||||
Map<String, Object> result = new LinkedCaseInsensitiveMap<>(); result.putAll(source); return result;
|
|
||||||
}
|
|
||||||
/** 解析不可变快照,损坏数据失败关闭。 */
|
|
||||||
private JsonNode parse(Object value) {
|
|
||||||
try { return value == null ? json.createObjectNode() : json.readTree(value.toString()); }
|
|
||||||
catch (Exception failure) { throw new FinanceEventException("FIN-DATA-500", "费用来源快照损坏"); }
|
|
||||||
}
|
|
||||||
/** 格式化记录金额,保留定点精度。 */
|
|
||||||
private static String amount(Map<String, Object> row) { return decimal(row.get("amount")).toPlainString(); }
|
|
||||||
/** 读取驱动返回的标准时间。 */
|
|
||||||
private static LocalDateTime time(Object value) { return value instanceof Timestamp time ? time.toLocalDateTime() : (LocalDateTime) value; }
|
|
||||||
/** 读取数字主键,仅用于数据引用。 */
|
|
||||||
private static long number(Object value) { return ((Number) value).longValue(); }
|
|
||||||
/** 保留未关联的可空引用。 */
|
|
||||||
private static Long nullableLong(Object value) { return value == null ? null : number(value); }
|
|
||||||
/** 只接受合法的正整型供应商引用。 */
|
|
||||||
private static Long positiveLong(String value) {
|
|
||||||
try { long parsed = Long.parseLong(value); return parsed > 0 ? parsed : null; }
|
|
||||||
catch (NumberFormatException failure) { return null; }
|
|
||||||
}
|
|
||||||
/** 读取可空金额,不经过浮点转换。 */
|
|
||||||
private static BigDecimal decimal(Object value) { return value == null ? null : new BigDecimal(value.toString()); }
|
|
||||||
/** 读取可空业务日期。 */
|
|
||||||
private static LocalDate date(String value) { return value == null ? null : LocalDate.parse(value); }
|
|
||||||
/** 保持空字段的缺失语义。 */
|
|
||||||
private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString(); }
|
|
||||||
/** 读取快照字段。 */
|
|
||||||
private static String text(JsonNode node, String field) { return node.path(field).isNull() || node.path(field).isMissingNode() ? null : text(node.path(field).asText()); }
|
|
||||||
/** 优先使用来源字段,其次使用已有正式默认值。 */
|
|
||||||
private static <T> T first(T value, T fallback) { return value == null ? fallback : value; }
|
|
||||||
}
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
package com.baiyue.finance.repository;
|
|
||||||
|
|
||||||
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */
|
|
||||||
@Repository
|
|
||||||
public class FinanceSettlementDetailRepository {
|
|
||||||
private final FinanceSettlementDetailMapper mapper;
|
|
||||||
/** 注入结算详情持久化入口。 */
|
|
||||||
public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; }
|
|
||||||
/** 保留原数据库列名不区分大小写的读取语义。 */
|
|
||||||
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
|
|
||||||
return rows.stream().map(row -> { Map<String, Object> copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList();
|
|
||||||
}
|
|
||||||
/** 读取平台结算主单及来源摘要。 */
|
|
||||||
public List<Map<String, Object>> summary(long settlementId) { return rows(mapper.summary(settlementId)); }
|
|
||||||
/** 读取该客户该结算的费用明细。 */
|
|
||||||
public List<Map<String, Object>> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); }
|
|
||||||
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
|
||||||
public List<Map<String, Object>> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); }
|
|
||||||
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
|
||||||
public List<Map<String, Object>> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); }
|
|
||||||
/** 读取该客户结算修订历史。 */
|
|
||||||
public List<Map<String, Object>> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); }
|
|
||||||
}
|
|
||||||
+35
-14
@@ -1,34 +1,55 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
import com.baiyue.common.core.number.DailyDocumentNumber;
|
|
||||||
import com.baiyue.finance.repository.DocumentSequenceRepository;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
|
||||||
@Component
|
@Component
|
||||||
public class FinanceDocumentNumberGenerator {
|
public class FinanceDocumentNumberGenerator {
|
||||||
private static final Set<String> PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP", "PAY");
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
private final DocumentSequenceRepository sequences;
|
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
||||||
|
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP");
|
||||||
|
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
/** 正式运行使用统一业务时区。 */
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences) {
|
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
|
||||||
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
|
this(jdbc, Clock.system(BUSINESS_ZONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 固定时钟用于跨日及前缀独立性验证。 */
|
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
|
||||||
FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
|
this.jdbc = jdbc;
|
||||||
this.sequences = sequences;
|
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只为本服务已登记的业务类型分配号码。 */
|
@Transactional(propagation = Propagation.MANDATORY)
|
||||||
public String next(String prefix) {
|
public String next(String prefix) {
|
||||||
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
|
if (!SUPPORTED_PREFIXES.contains(prefix)) {
|
||||||
return DailyDocumentNumber.next(prefix, clock, sequences::next);
|
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
|
||||||
|
}
|
||||||
|
LocalDate sequenceDate = LocalDate.now(clock);
|
||||||
|
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
|
||||||
|
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
||||||
|
"current_value=LAST_INSERT_ID(current_value+1)",
|
||||||
|
sequenceKey);
|
||||||
|
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||||
|
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
|
||||||
|
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
|
||||||
|
}
|
||||||
|
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
||||||
|
String.format(Locale.ROOT, "%04d", sequence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-21
@@ -1,10 +1,10 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
||||||
|
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
||||||
@@ -12,7 +12,6 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
|||||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import feign.FeignException;
|
import feign.FeignException;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -44,19 +43,19 @@ public class FinancePhase1OperationService {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FirstlegFeeSourceClient firstleg;
|
private final FirstlegFeeSourceClient firstleg;
|
||||||
private final String internalToken;
|
private final String internalToken;
|
||||||
private final FinanceFeeSourceReadRepository feeSources;
|
|
||||||
|
|
||||||
/** 注入费用来源查询及现有业务依赖。 */
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
FirstlegFeeSourceClient firstleg,
|
FirstlegFeeSourceClient firstleg,
|
||||||
@Value("${baiyue.security.internal-service-token}") String internalToken,
|
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||||
FinanceFeeSourceReadRepository feeSources) {
|
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.firstleg = firstleg;
|
this.firstleg = firstleg;
|
||||||
this.internalToken = internalToken;
|
this.internalToken = internalToken;
|
||||||
this.feeSources = feeSources;
|
}
|
||||||
|
|
||||||
|
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||||
|
this(jdbc, objectMapper, null, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
|
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
|
||||||
@@ -163,7 +162,6 @@ public class FinancePhase1OperationService {
|
|||||||
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */
|
|
||||||
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
||||||
String direction, String sourceStatus,
|
String direction, String sourceStatus,
|
||||||
String reviewStatus,
|
String reviewStatus,
|
||||||
@@ -179,9 +177,34 @@ public class FinancePhase1OperationService {
|
|||||||
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||||
String normalizedSourceStatus = normalized(sourceStatus,
|
String normalizedSourceStatus = normalized(sourceStatus,
|
||||||
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
||||||
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
|
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
|
||||||
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
|
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
|
||||||
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
|
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
|
||||||
|
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
|
||||||
|
List<Object> args = new ArrayList<>();
|
||||||
|
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
|
||||||
|
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
|
||||||
|
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
|
||||||
|
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
|
||||||
|
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
|
||||||
|
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
|
||||||
|
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
|
||||||
|
String normalizedKeyword = blankToNull(keyword);
|
||||||
|
if (normalizedKeyword != null) {
|
||||||
|
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
|
||||||
|
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
|
||||||
|
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
|
||||||
|
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
|
||||||
|
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
|
||||||
|
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
|
||||||
|
}
|
||||||
|
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
|
||||||
|
List<Object> queryArgs = new ArrayList<>(args);
|
||||||
|
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
|
||||||
|
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
|
||||||
|
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
|
||||||
|
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
|
||||||
|
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
||||||
@@ -315,7 +338,6 @@ public class FinancePhase1OperationService {
|
|||||||
return after;
|
return after;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */
|
|
||||||
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
|
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
|
||||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||||
long id = rs.getLong("id");
|
long id = rs.getLong("id");
|
||||||
@@ -333,7 +355,33 @@ public class FinancePhase1OperationService {
|
|||||||
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
|
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
|
||||||
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||||
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
|
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
|
||||||
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id));
|
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
|
||||||
|
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||||
|
boolean generated = rs.getBoolean("statement_generated");
|
||||||
|
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
|
||||||
|
BigDecimal amount = money(rs, "amount");
|
||||||
|
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
|
||||||
|
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0
|
||||||
|
? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
|
||||||
|
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
|
||||||
|
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
|
||||||
|
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
|
||||||
|
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
|
||||||
|
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
|
||||||
|
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
|
||||||
|
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
|
||||||
|
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
|
||||||
|
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||||
|
defaultString(rs.getString("review_status"), "DRAFT"),
|
||||||
|
rs.getString("effective_status"), generated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OperationLog> logs(long customerId, long id) {
|
||||||
|
return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) ->
|
||||||
|
new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
|
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
|
||||||
@@ -389,14 +437,7 @@ public class FinancePhase1OperationService {
|
|||||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
||||||
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
||||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
||||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
|
||||||
private String hash(Object value) {
|
|
||||||
try {
|
|
||||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
|
||||||
.writeValueAsBytes(value);
|
|
||||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
|
||||||
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
|
|
||||||
}
|
|
||||||
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
||||||
private static Long jsonLong(JsonNode node, String field) {
|
private static Long jsonLong(JsonNode node, String field) {
|
||||||
JsonNode value = node.path(field);
|
JsonNode value = node.path(field);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
@@ -307,14 +306,7 @@ public class FinanceService {
|
|||||||
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
|
||||||
private String hash(Object request) {
|
|
||||||
try {
|
|
||||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
|
||||||
.writeValueAsBytes(request == null ? List.of() : request);
|
|
||||||
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
|
|
||||||
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
||||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
@@ -45,29 +44,44 @@ import java.util.UUID;
|
|||||||
public class FinanceSettlementService {
|
public class FinanceSettlementService {
|
||||||
private static final String CNY = "CNY";
|
private static final String CNY = "CNY";
|
||||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||||
private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
|
private static final String SETTLEMENT_SUMMARY_SELECT =
|
||||||
|
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
||||||
|
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
||||||
|
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
||||||
|
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
||||||
|
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
||||||
|
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
||||||
|
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
||||||
|
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
||||||
|
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
||||||
|
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
||||||
|
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||||
|
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
||||||
|
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
||||||
|
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
||||||
|
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceVoucherValidator voucherValidator;
|
private final FinanceVoucherValidator voucherValidator;
|
||||||
private final FinanceSupplierValidator supplierValidator;
|
private final FinanceSupplierValidator supplierValidator;
|
||||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||||
|
FinanceSupplierValidator supplierValidator) {
|
||||||
|
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
|
||||||
|
}
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||||
FinanceSupplierValidator supplierValidator,
|
FinanceSupplierValidator supplierValidator,
|
||||||
FinanceDocumentNumberGenerator documentNumbers,
|
FinanceDocumentNumberGenerator documentNumbers) {
|
||||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
|
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.details = details;
|
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.voucherValidator = voucherValidator;
|
this.voucherValidator = voucherValidator;
|
||||||
this.supplierValidator = supplierValidator;
|
this.supplierValidator = supplierValidator;
|
||||||
this.documentNumbers = documentNumbers;
|
this.documentNumbers = documentNumbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
||||||
CreateReceivableSettlementRequest request) {
|
CreateReceivableSettlementRequest request) {
|
||||||
@@ -171,21 +185,33 @@ public class FinanceSettlementService {
|
|||||||
return getSettlement(settlementId);
|
return getSettlement(settlementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
|
|
||||||
public SettlementDetail getSettlement(long settlementId) {
|
public SettlementDetail getSettlement(long settlementId) {
|
||||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||||
List<Map<String, Object>> records = details.summary(settlementId);
|
Map<String, Object> row = one(
|
||||||
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
|
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
|
||||||
SettlementSummary summary = summary(records.get(0));
|
"结算单不存在", settlementId);
|
||||||
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
|
SettlementSummary summary = summary(row);
|
||||||
|
List<SettlementLine> lines = jdbc.queryForList(
|
||||||
|
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
|
||||||
|
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
|
||||||
|
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
|
||||||
|
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
|
||||||
.stream().map(source -> line(source, summary)).toList();
|
.stream().map(source -> line(source, summary)).toList();
|
||||||
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
|
||||||
.stream().map(this::transaction).toList());
|
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
|
||||||
|
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
|
||||||
|
summary.customerId(), settlementId).stream().map(this::transaction).toList());
|
||||||
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
||||||
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
|
transactions.addAll(jdbc.queryForList(
|
||||||
.stream().map(this::transaction).toList());
|
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " +
|
||||||
|
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||||
|
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
|
||||||
|
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
|
||||||
}
|
}
|
||||||
List<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
|
List<Map<String, Object>> audits = jdbc.queryForList(
|
||||||
|
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
|
||||||
|
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
|
||||||
|
summary.customerId(), settlementId);
|
||||||
List<SettlementRevision> revisions = new ArrayList<>();
|
List<SettlementRevision> revisions = new ArrayList<>();
|
||||||
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
|
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
|
||||||
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
||||||
@@ -453,9 +479,8 @@ public class FinanceSettlementService {
|
|||||||
text(row, "status"), true);
|
text(row, "status"), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
|
|
||||||
private SettlementTransaction transaction(Map<String, Object> row) {
|
private SettlementTransaction transaction(Map<String, Object> row) {
|
||||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
|
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
|
||||||
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
||||||
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
||||||
text(row, "created_by_name"),
|
text(row, "created_by_name"),
|
||||||
@@ -496,18 +521,15 @@ public class FinanceSettlementService {
|
|||||||
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 结算收付款使用独立 PAY 日流水,重试由原业务幂等记录恢复。 */
|
|
||||||
private String transactionNo() {
|
private String transactionNo() {
|
||||||
return documentNumbers.next("PAY");
|
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
|
||||||
|
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
|
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try {
|
try {
|
||||||
String canonical = objectMapper.writer()
|
|
||||||
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
|
|
||||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
|
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
throw error("FIN_001", "幂等请求哈希失败");
|
throw error("FIN_001", "幂等请求哈希失败");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
|||||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -695,9 +694,8 @@ public class FinanceWalletService {
|
|||||||
timestamp(rs.getTimestamp("reviewed_at")));
|
timestamp(rs.getTimestamp("reviewed_at")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
||||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||||
}
|
}
|
||||||
private String fileHash(MultipartFile file) {
|
private String fileHash(MultipartFile file) {
|
||||||
|
|||||||
+6
-9
@@ -10,7 +10,6 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
|||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
@@ -47,7 +46,10 @@ public class ReconciliationStatementService {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||||
|
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
|
||||||
|
}
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
FinanceDocumentNumberGenerator documentNumbers) {
|
FinanceDocumentNumberGenerator documentNumbers) {
|
||||||
@@ -56,7 +58,6 @@ public class ReconciliationStatementService {
|
|||||||
this.documentNumbers = documentNumbers;
|
this.documentNumbers = documentNumbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
||||||
validateCreate(request);
|
validateCreate(request);
|
||||||
@@ -522,13 +523,9 @@ public class ReconciliationStatementService {
|
|||||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try {
|
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
||||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||||
.writeValueAsBytes(value);
|
|
||||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
|
||||||
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LocalDateTime localTime(Object value) {
|
private static LocalDateTime localTime(Object value) {
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ CREATE TABLE IF NOT EXISTS fin_document_sequence (
|
|||||||
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
|
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
|
||||||
) ENGINE=InnoDB COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
|
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
@@ -524,7 +524,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
|||||||
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||||
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
|
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
|
||||||
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
|
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
|
||||||
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
|
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
|
||||||
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
|
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
|
||||||
@@ -549,7 +549,7 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
|||||||
CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||||
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
|
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
||||||
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||||
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||||
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
|
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class FinanceContractTest {
|
|||||||
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
|
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */
|
|
||||||
@Test
|
@Test
|
||||||
void controllersUseFrozenFinancePaths() throws Exception {
|
void controllersUseFrozenFinancePaths() throws Exception {
|
||||||
assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]);
|
assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]);
|
||||||
@@ -41,8 +40,7 @@ class FinanceContractTest {
|
|||||||
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && 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())"));
|
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
||||||
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
|
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
|
||||||
String feeReads = Files.readString(Path.of("src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java"));
|
assertTrue(operations.contains("bl.source_id=f.id"));
|
||||||
assertTrue(feeReads.contains("bl.source_id=f.id"));
|
|
||||||
assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")"));
|
assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")"));
|
||||||
assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
|
assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
|
||||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
package com.baiyue.finance;
|
|
||||||
|
|
||||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
|
||||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.apache.ibatis.mapping.Environment;
|
|
||||||
import org.apache.ibatis.session.Configuration;
|
|
||||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
|
||||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/** 隔离数据库执行正式 Mapper,验证版本归并与历史不会改变金额和客户归属。 */
|
|
||||||
public class FinanceFeeSourceReadMapperTest {
|
|
||||||
/** 应收和应付均以来源身份替换当前展示,同名费用、其他来源类型和系统保持独立。 */
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
|
|
||||||
void currentProjectionAndHistoryKeepStableFeeIdentity(String direction) {
|
|
||||||
var data = new DriverManagerDataSource("jdbc:h2:mem:fee-current-" + UUID.randomUUID() + ";MODE=MySQL", "sa", "");
|
|
||||||
var jdbc = new JdbcTemplate(data);
|
|
||||||
// 保持一个会话存活;Mapper 使用同一隔离数据库,绝不访问共享测试环境。
|
|
||||||
try (var connection = data.getConnection()) {
|
|
||||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'");
|
|
||||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'");
|
|
||||||
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)");
|
|
||||||
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)");
|
|
||||||
jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)");
|
|
||||||
insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
|
|
||||||
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00");
|
|
||||||
insert(jdbc, 3, "FIRSTLEG", "INBOUND_FEE", 701, 1, 9, direction, "AVAILABLE", "80.00");
|
|
||||||
insert(jdbc, 4, "FIRSTLEG", "INBOUND_FEE", 702, 1, 9, direction, "AVAILABLE", "50.00");
|
|
||||||
insert(jdbc, 5, "FIRSTLEG", "INBOUND_FEE", 702, 2, 9, direction, "VOIDED", "70.00");
|
|
||||||
insert(jdbc, 6, "LASTLEG", "INBOUND_FEE", 700, 1, 9, direction, "AVAILABLE", "33.00");
|
|
||||||
insert(jdbc, 7, "FIRSTLEG", "CONTAINER_FEE", 700, 1, 9, direction, "AVAILABLE", "44.00");
|
|
||||||
insert(jdbc, 8, "FIRSTLEG", "INBOUND_FEE", 703, 1, 10, direction, "AVAILABLE", "55.00");
|
|
||||||
jdbc.update("INSERT INTO fin_audit_log VALUES(1,9,'FIN_FEE_SOURCE',1,'FEE_SOURCE_REVIEW_APPROVE','审核员','原费用审核',NULL,NULL,'2026-10-21 10:01:30')");
|
|
||||||
jdbc.update("INSERT INTO fin_audit_log VALUES(2,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_ADJUST','财务员','更正费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:03:00')");
|
|
||||||
jdbc.update("INSERT INTO fin_audit_log VALUES(3,9,'FIN_FEE_SOURCE',3,'FEE_SOURCE_ADJUST','其他费用','不能合并',NULL,NULL,'2026-10-21 10:04:00')");
|
|
||||||
jdbc.update("INSERT INTO fin_audit_log VALUES(4,10,'FIN_FEE_SOURCE',1,'FEE_SOURCE_ADJUST','其他客户','不能越界',NULL,NULL,'2026-10-21 10:04:00')");
|
|
||||||
|
|
||||||
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), data));
|
|
||||||
configuration.addMapper(FinanceFeeSourceReadMapper.class);
|
|
||||||
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
|
||||||
var repository = new FinanceFeeSourceReadRepository(session.getMapper(FinanceFeeSourceReadMapper.class), new ObjectMapper());
|
|
||||||
var filters = repository.filters(9L, direction, null, "APPROVED", false,
|
|
||||||
LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 31), "操作费", 1, 20);
|
|
||||||
assertThat(repository.count(filters)).isEqualTo(5);
|
|
||||||
var rows = repository.list(filters);
|
|
||||||
assertThat(rows).extracting(row -> row.feeSourceId()).containsExactly(7L, 6L, 5L, 3L, 2L);
|
|
||||||
assertThat(rows.stream().map(row -> row.amount()).reduce(BigDecimal.ZERO, BigDecimal::add))
|
|
||||||
.isEqualByComparingTo("347.00");
|
|
||||||
assertThat(rows).allSatisfy(row -> {
|
|
||||||
assertThat(row.direction()).isEqualTo(direction);
|
|
||||||
assertThat(row.customerId()).isEqualTo(9);
|
|
||||||
assertThat(row.supplierId()).isEqualTo(79);
|
|
||||||
assertThat(row.inboundCode()).isEqualTo("IN-9");
|
|
||||||
assertThat(row.businessDate()).isEqualTo(LocalDate.of(2026, 10, 21));
|
|
||||||
assertThat(row.originalAmount()).isEqualByComparingTo("200");
|
|
||||||
assertThat(row.unitPrice()).isEqualByComparingTo("20");
|
|
||||||
});
|
|
||||||
filters.put("limit", 2);
|
|
||||||
filters.put("offset", 2);
|
|
||||||
assertThat(repository.count(filters)).isEqualTo(5);
|
|
||||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(5L, 3L);
|
|
||||||
filters.put("limit", 20); filters.put("offset", 0); filters.put("status", "AVAILABLE");
|
|
||||||
assertThat(repository.count(filters)).isEqualTo(4);
|
|
||||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).doesNotContain(4L, 5L);
|
|
||||||
filters.put("status", "SUPERSEDED");
|
|
||||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(1L);
|
|
||||||
|
|
||||||
var logs = repository.logs(9, 2);
|
|
||||||
assertThat(logs).hasSize(4);
|
|
||||||
assertThat(logs).extracting(log -> log.action()).containsExactly(
|
|
||||||
"FEE_SOURCE_ADJUST", "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_PUBLISHED");
|
|
||||||
assertThat(logs.get(0).remark()).isEqualTo("更正费用;金额 100 → 120 CNY");
|
|
||||||
assertThat(logs.get(1).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
|
|
||||||
assertThat(logs).noneSatisfy(log -> assertThat(log.operatorName()).startsWith("其他"));
|
|
||||||
assertThat(repository.logs(9, 2)).isEqualTo(logs);
|
|
||||||
assertThat(repository.logs(9, 1)).hasSize(2);
|
|
||||||
assertThat(repository.logs(10, 2)).isEmpty();
|
|
||||||
// 同毫秒必须先展示新版本;审计仍按 ID 倒序,不能反转金额差分或突破旧版本锚点。
|
|
||||||
jdbc.update("UPDATE fin_fee_source SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
|
|
||||||
jdbc.update("UPDATE fin_audit_log SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
|
|
||||||
jdbc.update("INSERT INTO fin_audit_log VALUES(6,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_REVIEW_APPROVE','审核员','同毫秒后审计',NULL,NULL,'2026-10-21 10:03:00.123')");
|
|
||||||
session.clearCache();
|
|
||||||
var sameTimeLogs = repository.logs(9, 2);
|
|
||||||
assertThat(sameTimeLogs).extracting(log -> log.action()).containsExactly(
|
|
||||||
"FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE",
|
|
||||||
"FEE_SOURCE_ADJUST", "FEE_SOURCE_REVIEW_APPROVE");
|
|
||||||
assertThat(sameTimeLogs.get(0).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
|
|
||||||
assertThat(sameTimeLogs.get(1).remark()).contains("来源版本 1;金额 100.000000 CNY");
|
|
||||||
assertThat(sameTimeLogs.subList(2, 5)).extracting(log -> log.remark()).containsExactly(
|
|
||||||
"同毫秒后审计", "更正费用;金额 100 → 120 CNY", "原费用审核");
|
|
||||||
assertThat(repository.logs(9, 2)).isEqualTo(sameTimeLogs);
|
|
||||||
assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
|
|
||||||
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
|
|
||||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
|
|
||||||
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)");
|
|
||||||
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
|
|
||||||
assertThat(repository.count(filters)).isEqualTo(1);
|
|
||||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
|
|
||||||
|
|
||||||
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
|
|
||||||
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
|
|
||||||
jdbc.update("INSERT INTO fin_audit_log VALUES(5,9,'FIN_FEE_SOURCE',9,'FEE_SOURCE_ADJUST','财务员','更正人工费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:05:00')");
|
|
||||||
assertThat(repository.logs(9, 9)).singleElement().satisfies(log -> {
|
|
||||||
assertThat(log.action()).isEqualTo("FEE_SOURCE_ADJUST");
|
|
||||||
assertThat(log.remark()).isEqualTo("更正人工费用;金额 100 → 120 CNY");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (java.sql.SQLException failure) { throw new AssertionError(failure); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 准备具有相同名称、不同稳定来源的隔离费用快照。 */
|
|
||||||
private static void insert(JdbcTemplate jdbc, long id, String system, String type, long fee, int version,
|
|
||||||
long customer, String direction, String status, String amount) {
|
|
||||||
jdbc.update("INSERT INTO fin_fee_source(id,source_system,source_type,source_fee_id,source_fee_version,customer_id,direction,source_no,fee_item_name,amount,currency_code,review_status,status,source_snapshot,created_at) VALUES(?,?,?,?,?,?,?,'IN-9','操作费',?,'CNY','APPROVED',?,?,?)",
|
|
||||||
id, system, type, fee, version, customer, direction, new BigDecimal(amount), status,
|
|
||||||
"{\"businessDate\":\"2026-10-21\",\"supplierId\":79,\"inboundCode\":\"IN-9\",\"chargeableQuantity\":10,\"originalAmount\":200}",
|
|
||||||
java.sql.Timestamp.valueOf("2026-10-21 10:0" + version + ":00"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** H2 仅补足 MySQL JSON 读取函数,实际筛选、关联和分页仍执行正式 SQL。 */
|
|
||||||
public static String jsonExtract(String value, String path) throws Exception {
|
|
||||||
var node = new ObjectMapper().readTree(value).path(path.substring(2));
|
|
||||||
return node.isMissingNode() || node.isNull() ? null : node.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 对测试 JSON 标量解引号,保持 MySQL 空值语义。 */
|
|
||||||
public static String jsonUnquote(String value) throws Exception {
|
|
||||||
return value == null ? null : new ObjectMapper().readTree(value).asText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,13 +9,10 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
|||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||||
import com.baiyue.finance.service.FinanceEventException;
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
|
||||||
import com.baiyue.finance.service.FinanceCustomerAccountService;
|
import com.baiyue.finance.service.FinanceCustomerAccountService;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -43,9 +40,6 @@ import static org.mockito.Mockito.lenient;
|
|||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.mockito.Mockito.spy;
|
|
||||||
import static org.mockito.Mockito.doReturn;
|
|
||||||
import static org.mockito.Mockito.times;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class FinancePhase1OperationBehaviorTest {
|
class FinancePhase1OperationBehaviorTest {
|
||||||
@@ -87,20 +81,18 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
assertThat(response.page().total()).isEqualTo(1);
|
assertThat(response.page().total()).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 无效客户在进入持久化查询前拒绝。 */
|
|
||||||
@Test
|
@Test
|
||||||
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
|
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper());
|
||||||
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
|
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保留客户账户汇总的固定分页与金额口径。 */
|
|
||||||
@Test
|
@Test
|
||||||
void accountListReturnsFixedPagedDto() {
|
void accountListReturnsFixedPagedDto() {
|
||||||
AccountJdbc jdbc = new AccountJdbc();
|
AccountJdbc jdbc = new AccountJdbc();
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
|
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
|
||||||
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
|
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
|
||||||
@@ -120,11 +112,10 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
|
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 所有派生流水查询保留目标客户范围。 */
|
|
||||||
@Test
|
@Test
|
||||||
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
|
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
|
||||||
AccountJdbc jdbc = new AccountJdbc();
|
AccountJdbc jdbc = new AccountJdbc();
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
||||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||||
@@ -137,24 +128,58 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
|
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */
|
|
||||||
@Test
|
@Test
|
||||||
void feeSourceListUsesFormalFieldsAndAllActiveFilters() {
|
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
|
||||||
var reads = mock(FinanceFeeSourceReadRepository.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
var filters = java.util.Map.<String, Object>of("query", "current");
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
|
||||||
when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false,
|
.thenReturn(1L);
|
||||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters);
|
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
|
||||||
when(reads.list(filters)).thenReturn(List.of());
|
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||||
when(reads.count(filters)).thenReturn(3L);
|
ResultSet rs = mock(ResultSet.class);
|
||||||
var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads);
|
when(rs.getLong("id")).thenReturn(88L);
|
||||||
|
when(rs.getLong("source_fee_id")).thenReturn(9001L);
|
||||||
|
when(rs.getInt("source_fee_version")).thenReturn(3);
|
||||||
|
when(rs.getLong("customer_id")).thenReturn(9L);
|
||||||
|
when(rs.getLong("order_id")).thenReturn(19L);
|
||||||
|
when(rs.getLong("inbound_id")).thenReturn(29L);
|
||||||
|
when(rs.getLong("container_id")).thenReturn(39L);
|
||||||
|
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
|
||||||
|
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
|
||||||
|
when(rs.getString("source_no")).thenReturn("IN-9");
|
||||||
|
when(rs.getString("direction")).thenReturn("RECEIVABLE");
|
||||||
|
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
|
||||||
|
when(rs.getString("currency_code")).thenReturn("CNY");
|
||||||
|
when(rs.getString("review_status")).thenReturn("APPROVED");
|
||||||
|
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
|
||||||
|
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
|
||||||
|
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
|
||||||
|
when(rs.getBoolean("statement_generated")).thenReturn(false);
|
||||||
|
return List.of(mapper.mapRow(rs, 0));
|
||||||
|
});
|
||||||
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
||||||
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
||||||
assertThat(result.page().total()).isEqualTo(3);
|
|
||||||
assertThat(result.page().page()).isEqualTo(2);
|
assertThat(result.page()).satisfies(meta -> {
|
||||||
verify(reads).list(filters);
|
assertThat(meta.page()).isEqualTo(2);
|
||||||
|
assertThat(meta.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(meta.total()).isEqualTo(1);
|
||||||
|
});
|
||||||
|
assertThat(result.data()).singleElement().satisfies(source -> {
|
||||||
|
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
|
||||||
|
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
|
||||||
|
assertThat(source.inboundCode()).isEqualTo("IN-9");
|
||||||
|
assertThat(source.containerNo()).isEqualTo("CONT-9");
|
||||||
|
assertThat(source.supplierId()).isEqualTo(79L);
|
||||||
|
assertThat(source.supplierName()).isEqualTo("供应商A");
|
||||||
|
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
|
||||||
|
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
|
||||||
|
assertThat(source.statementGenerated()).isFalse();
|
||||||
|
});
|
||||||
|
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 流水返回业务单号和操作者快照。 */
|
|
||||||
@Test
|
@Test
|
||||||
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
|
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
@@ -172,7 +197,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||||
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
||||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||||
@@ -186,20 +211,18 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保留费用审核状态读取。 */
|
|
||||||
@Test
|
@Test
|
||||||
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
||||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 费用详情保持原客户与来源版本。 */
|
|
||||||
@Test
|
@Test
|
||||||
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||||
feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
feeSourceJdbc("APPROVED", true, 2), objectMapper());
|
||||||
|
|
||||||
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
||||||
|
|
||||||
@@ -209,11 +232,10 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 业务来源费用必须回到所属系统调整。 */
|
|
||||||
@Test
|
@Test
|
||||||
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
||||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
|
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
||||||
new BigDecimal("120.00"), "更正金额", 1);
|
new BigDecimal("120.00"), "更正金额", 1);
|
||||||
@@ -225,7 +247,6 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
(Object[]) any());
|
(Object[]) any());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
|
|
||||||
@Test
|
@Test
|
||||||
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
|
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
|
||||||
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
|
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
|
||||||
@@ -236,7 +257,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
||||||
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||||
jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
|
jdbc, objectMapper(), firstleg, "internal-token");
|
||||||
|
|
||||||
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
|
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
|
||||||
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
||||||
@@ -257,7 +278,6 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
(Object[]) any());
|
(Object[]) any());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审核以当前操作版本更新并追加同一记录的审计。 */
|
|
||||||
@Test
|
@Test
|
||||||
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
@@ -269,6 +289,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
return List.of(mapper.mapRow(resultSet(review, false, version), 0));
|
return List.of(mapper.mapRow(resultSet(review, false, version), 0));
|
||||||
});
|
});
|
||||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
||||||
|
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||||
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
||||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
||||||
@@ -280,7 +301,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
|
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
|
||||||
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
|
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
|
||||||
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
|
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||||
|
|
||||||
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
||||||
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
||||||
@@ -290,51 +311,6 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
|
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 财务内部应收应付调整原费用记录,重复提交不能新增费用或重复记审计。 */
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
|
|
||||||
void manualAdjustmentUpdatesOriginalFeeOnceAndChecksVersionAndFrozenState(String direction) throws Exception {
|
|
||||||
var jdbc = mock(JdbcTemplate.class);
|
|
||||||
var service = spy(new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)));
|
|
||||||
var beforeJson = objectMapper().createObjectNode().put("feeSourceId", 88).put("sourceSystem", "FINANCE")
|
|
||||||
.put("sourceType", "MANUAL").put("sourceFeeId", 700).put("sourceVersion", 1).put("customerId", 9)
|
|
||||||
.put("direction", direction).put("amount", 100).put("currency", "CNY").put("operationVersion", 2)
|
|
||||||
.put("reviewStatus", "APPROVED").put("sourceStatus", "AVAILABLE").put("statementGenerated", false);
|
|
||||||
var before = objectMapper().treeToValue(beforeJson, FeeSourceDetail.class);
|
|
||||||
var after = objectMapper().treeToValue(beforeJson.deepCopy().put("amount", 120)
|
|
||||||
.put("operationVersion", 3).put("reviewStatus", "DRAFT"), FeeSourceDetail.class);
|
|
||||||
doReturn(before, after).when(service).getFinanceFeeSource(88L);
|
|
||||||
lenient().when(jdbc.update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED")))
|
|
||||||
.thenReturn(1, 0);
|
|
||||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "调整金额", 2);
|
|
||||||
var result = service.editFinanceFeeSource(88, 71, "adjust-once", request, true);
|
|
||||||
assertThat(result.feeSourceId()).isEqualTo(88);
|
|
||||||
assertThat(result.sourceFeeId()).isEqualTo(700);
|
|
||||||
assertThat(result.direction()).isEqualTo(direction);
|
|
||||||
assertThat(result.amount()).isEqualByComparingTo("120");
|
|
||||||
assertThat(result.operationVersion()).isEqualTo(3);
|
|
||||||
var hash = org.mockito.ArgumentCaptor.forClass(String.class);
|
|
||||||
verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
|
||||||
eq("FEE_SOURCE_ADJUST"), eq("adjust-once"), hash.capture());
|
|
||||||
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_ADJUST"), eq("adjust-once")))
|
|
||||||
.thenReturn(List.of(java.util.Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L)));
|
|
||||||
assertThat(service.editFinanceFeeSource(88, 71, "adjust-once", request, true)).isEqualTo(after);
|
|
||||||
verify(jdbc, times(1)).update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED"));
|
|
||||||
verify(jdbc, times(1)).update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), eq(88L),
|
|
||||||
eq("FEE_SOURCE_ADJUST"), eq("调整金额"), anyString(), anyString(), eq(71L), anyString(), any(), any());
|
|
||||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
|
||||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "adjust-once",
|
|
||||||
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("121.00"), "调整金额", 2), true))
|
|
||||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Idempotency-Key已用于其他请求");
|
|
||||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "stale-version", request, true))
|
|
||||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("状态或版本");
|
|
||||||
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("statementGenerated", true), FeeSourceDetail.class))
|
|
||||||
.when(service).getFinanceFeeSource(88L);
|
|
||||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
|
|
||||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */
|
|
||||||
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
|
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||||
@@ -342,6 +318,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0));
|
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0));
|
||||||
});
|
});
|
||||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
||||||
|
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||||
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||||
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
|
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
|
||||||
return jdbc;
|
return jdbc;
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class FinancePlatformListContractTest {
|
|||||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
|
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
|
||||||
"operatorId", "operatorName");
|
"operatorId", "operatorName");
|
||||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
|
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
|
||||||
"createdBy", "createdByName", "transactionNo");
|
"createdBy", "createdByName");
|
||||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
|
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
|
||||||
"operatorId", "operatorName");
|
"operatorId", "operatorName");
|
||||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
|
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest {
|
|||||||
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
|
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
|
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
|
||||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
|
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
||||||
Map.entry("id", 501L), Map.entry("transaction_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"),
|
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
|
||||||
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
|
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
|
||||||
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
|
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
|
||||||
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
|
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
|
||||||
@@ -258,7 +258,6 @@ class FinanceSettlementBehaviorTest {
|
|||||||
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
|
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
|
||||||
.isEqualTo("PAYMENT");
|
.isEqualTo("PAYMENT");
|
||||||
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
||||||
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
|
|
||||||
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
|
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
|
||||||
any(), any(), any(), any(), any(), any(), any(), any());
|
any(), any(), any(), any(), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
@@ -523,7 +522,6 @@ class FinanceSettlementBehaviorTest {
|
|||||||
.isEqualTo("RECEIPT");
|
.isEqualTo("RECEIPT");
|
||||||
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
|
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
|
||||||
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
||||||
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -622,18 +620,10 @@ class FinanceSettlementBehaviorTest {
|
|||||||
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
|
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
|
||||||
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
|
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
|
||||||
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
|
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
|
||||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001");
|
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
|
||||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001");
|
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
|
||||||
org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001");
|
|
||||||
// 既有业务测试保留JDBC替身,新详情路径通过真实Repository和Mapper接口适配执行。
|
|
||||||
var mapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementDetailMapper.class, invocation -> {
|
|
||||||
var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
|
|
||||||
if (select == null) return RETURNS_DEFAULTS.answer(invocation);
|
|
||||||
String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
|
|
||||||
return jdbc.queryForList(sql, invocation.getArguments());
|
|
||||||
});
|
|
||||||
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
|
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FinanceSupplierValidator supplierValidator() {
|
private static FinanceSupplierValidator supplierValidator() {
|
||||||
@@ -666,7 +656,7 @@ class FinanceSettlementBehaviorTest {
|
|||||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
|
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
|
||||||
long customerId = ((Number) settlement.get("tenant_id")).longValue();
|
long customerId = ((Number) settlement.get("tenant_id")).longValue();
|
||||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
|
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
|
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package com.baiyue.finance;
|
|
||||||
|
|
||||||
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
|
||||||
import com.baiyue.finance.repository.FinanceSettlementDetailRepository;
|
|
||||||
import org.apache.ibatis.mapping.Environment;
|
|
||||||
import org.apache.ibatis.session.Configuration;
|
|
||||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
|
||||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
class FinanceSettlementDetailMapperTest {
|
|
||||||
/** 真实执行付款查询,业务号必须来自本客户本结算的流水,空号不以其他字段替代。 */
|
|
||||||
@Test
|
|
||||||
void paymentNumbersRemainScopedToCustomerAndSettlement() {
|
|
||||||
var source = new DriverManagerDataSource("jdbc:h2:mem:settlement-number;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
|
||||||
var jdbc = new JdbcTemplate(source);
|
|
||||||
jdbc.execute("CREATE TABLE fin_settlement_payment (id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,transaction_no VARCHAR(32),transaction_type VARCHAR(32),transaction_date DATE,amount DECIMAL(18,2),method VARCHAR(32),reference_no VARCHAR(64),voucher_file_ids VARCHAR(255),created_by BIGINT,created_by_name VARCHAR(64),created_at TIMESTAMP,remark VARCHAR(255))");
|
|
||||||
jdbc.update("INSERT INTO fin_settlement_payment (id,tenant_id,settlement_id,transaction_no,transaction_type,reference_no) VALUES (1,10,20,'PAY202610210001','PAYMENT','BANK-1'),(2,11,20,'PAY202610210002','PAYMENT','BANK-2'),(3,10,21,'PAY202610210003','PAYMENT','BANK-3'),(4,10,22,NULL,'PAYMENT','BANK-4')");
|
|
||||||
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
|
|
||||||
configuration.addMapper(FinanceSettlementDetailMapper.class);
|
|
||||||
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
|
||||||
var repository = new FinanceSettlementDetailRepository(session.getMapper(FinanceSettlementDetailMapper.class));
|
|
||||||
var rows = repository.payments(10, 20);
|
|
||||||
assertThat(rows).hasSize(1);
|
|
||||||
assertThat(rows.get(0).get("transaction_no")).isEqualTo("PAY202610210001");
|
|
||||||
assertThat(rows.get(0).get("reference_no")).isEqualTo("BANK-1");
|
|
||||||
assertThat(repository.payments(11, 21)).isEmpty();
|
|
||||||
assertThat(repository.payments(10, 22)).hasSize(1);
|
|
||||||
assertThat(repository.payments(10, 22).get(0).get("transaction_no")).isNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package com.baiyue.finance;
|
|
||||||
|
|
||||||
import com.baiyue.finance.service.FinanceSettlementService;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import com.baiyue.finance.service.FinanceWalletService;
|
|
||||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
|
||||||
import com.baiyue.finance.service.FinanceService;
|
|
||||||
import com.baiyue.finance.service.ReconciliationStatementService;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
class FinanceSettlementRequestHashTest {
|
|
||||||
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
|
|
||||||
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
|
|
||||||
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
|
|
||||||
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
|
|
||||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
|
||||||
body.put("amount", 190); body.put("expectedVersion", 0);
|
|
||||||
Map<String, Object> first = new LinkedHashMap<>();
|
|
||||||
first.put("path", "/settlements/202/transactions"); first.put("method", "POST");
|
|
||||||
first.put("customerId", 10L); first.put("body", body);
|
|
||||||
Map<String, Object> reversedBody = new LinkedHashMap<>();
|
|
||||||
reversedBody.put("expectedVersion", 0); reversedBody.put("amount", 190);
|
|
||||||
Map<String, Object> second = new LinkedHashMap<>();
|
|
||||||
second.put("body", reversedBody); second.put("customerId", 10L);
|
|
||||||
second.put("method", "POST"); second.put("path", "/settlements/202/transactions");
|
|
||||||
|
|
||||||
String original = ReflectionTestUtils.invokeMethod(service, "hash", first);
|
|
||||||
String reordered = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
|
||||||
assertThat(reordered).isEqualTo(original);
|
|
||||||
reversedBody.put("amount", 191);
|
|
||||||
String changed = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
|
||||||
assertThat(changed).isNotEqualTo(original);
|
|
||||||
second.put("customerId", 11L); reversedBody.put("amount", 190);
|
|
||||||
String otherCustomer = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
|
||||||
assertThat(otherCustomer).isNotEqualTo(original);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -70,7 +70,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
|
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L),
|
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L),
|
||||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
var result = target.create(71L, "create-key", request);
|
var result = target.create(71L, "create-key", request);
|
||||||
|
|
||||||
assertThat(result.statementId()).isEqualTo(501L);
|
assertThat(result.statementId()).isEqualTo(501L);
|
||||||
assertThat(result.statementNo()).isEqualTo("REC202608180001");
|
assertThat(result.statementNo()).isEqualTo("REC-260818-0001");
|
||||||
assertThat(result.currency()).isEqualTo("CNY");
|
assertThat(result.currency()).isEqualTo("CNY");
|
||||||
assertThat(result.createdByName()).isEqualTo("platform_admin");
|
assertThat(result.createdByName()).isEqualTo("platform_admin");
|
||||||
assertThat(result.updatedByName()).isEqualTo("platform_admin");
|
assertThat(result.updatedByName()).isEqualTo("platform_admin");
|
||||||
@@ -132,7 +132,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
|
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
|
||||||
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L)));
|
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L)));
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY",
|
assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY",
|
||||||
"SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null)))
|
"SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null)))
|
||||||
@@ -144,7 +144,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
|
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
|
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "submit-key",
|
var result = target.action(71L, 501L, "submit-key",
|
||||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()));
|
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()));
|
||||||
@@ -164,7 +164,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void approveFreezesInternalBillAndReturnsContractStatus() throws Exception {
|
void approveFreezesInternalBillAndReturnsContractStatus() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key");
|
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key");
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "approve-key",
|
var result = target.action(71L, 501L, "approve-key",
|
||||||
new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of()));
|
new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of()));
|
||||||
@@ -177,7 +177,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void issueFreezesSourcesOnlyAfterApproval() throws Exception {
|
void issueFreezesSourcesOnlyAfterApproval() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key");
|
JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key");
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "issue-key",
|
var result = target.action(71L, 501L, "issue-key",
|
||||||
new ReconciliationActionRequest("ISSUE", null, 2, List.of()));
|
new ReconciliationActionRequest("ISSUE", null, 2, List.of()));
|
||||||
@@ -202,7 +202,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
|
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
|
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
|
||||||
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
assertThatThrownBy(() -> serviceWithNumber(crossCustomer).confirmForCustomer(
|
assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer(
|
||||||
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
|
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
|
||||||
@@ -251,7 +251,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
|
||||||
assertThatThrownBy(() -> serviceWithNumber(draft).confirmForCustomer(
|
assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer(
|
||||||
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
|
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
@@ -271,7 +271,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
|
||||||
assertThatThrownBy(() -> serviceWithNumber(stale).confirmForCustomer(
|
assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer(
|
||||||
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
|
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
@@ -288,7 +288,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of(
|
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of(
|
||||||
"request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L,
|
"request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L,
|
||||||
"updated_at", LocalDateTime.now())));
|
"updated_at", LocalDateTime.now())));
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
|
assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
|
||||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
|
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
|
||||||
@@ -300,7 +300,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
|
void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
|
JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key",
|
assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key",
|
||||||
new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of())))
|
new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of())))
|
||||||
@@ -318,7 +318,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key");
|
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key");
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
|
||||||
.thenReturn(new BigDecimal("280.00"));
|
.thenReturn(new BigDecimal("280.00"));
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3,
|
target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3,
|
||||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正"))));
|
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正"))));
|
||||||
@@ -342,7 +342,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
return List.of(mapper.mapRow(rs, 0));
|
return List.of(mapper.mapRow(rs, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L,
|
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
||||||
"low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3,
|
"low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3,
|
||||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误")))))
|
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误")))))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
@@ -355,7 +355,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
|
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "void-key",
|
var result = target.action(71L, 501L, "void-key",
|
||||||
new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of()));
|
new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of()));
|
||||||
@@ -372,7 +372,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
.thenReturn("receipt".equals(query) ? 1L : 0L);
|
.thenReturn("receipt".equals(query) ? 1L : 0L);
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L)))
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L)))
|
||||||
.thenReturn("settlement".equals(query) ? 1L : 0L);
|
.thenReturn("settlement".equals(query) ? 1L : 0L);
|
||||||
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L,
|
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
||||||
"void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of())))
|
"void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of())))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
@@ -384,7 +384,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception {
|
void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key");
|
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key");
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1);
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1);
|
||||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "reopen-key",
|
var result = target.action(71L, 501L, "reopen-key",
|
||||||
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()));
|
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()));
|
||||||
@@ -399,7 +399,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation ->
|
JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation ->
|
||||||
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||||
when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of());
|
when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of());
|
||||||
ReconciliationStatementService firstService = serviceWithNumber(firstJdbc);
|
ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body);
|
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body);
|
||||||
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
|
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
|
||||||
@@ -412,7 +412,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED",
|
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED",
|
||||||
"result_resource_id", 501L, "updated_at", LocalDateTime.now())));
|
"result_resource_id", 501L, "updated_at", LocalDateTime.now())));
|
||||||
assertThat((Long) ReflectionTestUtils.invokeMethod(
|
assertThat((Long) ReflectionTestUtils.invokeMethod(
|
||||||
serviceWithNumber(replayJdbc),
|
new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()),
|
||||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body))
|
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body))
|
||||||
.isEqualTo(501L);
|
.isEqualTo(501L);
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key")))
|
when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key")))
|
||||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
|
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
|
||||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
|
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
|
||||||
serviceWithNumber(processingJdbc),
|
new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()),
|
||||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body))
|
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
@@ -431,7 +431,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
|
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
|
||||||
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key")))
|
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key")))
|
||||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
|
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
|
||||||
ReconciliationStatementService recoveryService = serviceWithNumber(recoveryJdbc);
|
ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules());
|
||||||
|
|
||||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull();
|
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull();
|
||||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull();
|
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull();
|
||||||
@@ -556,7 +556,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
ResultSet rs = mock(ResultSet.class);
|
ResultSet rs = mock(ResultSet.class);
|
||||||
when(rs.getLong("id")).thenReturn(501L);
|
when(rs.getLong("id")).thenReturn(501L);
|
||||||
when(rs.getLong("tenant_id")).thenReturn(9L);
|
when(rs.getLong("tenant_id")).thenReturn(9L);
|
||||||
when(rs.getString("bill_no")).thenReturn("REC202608180001");
|
when(rs.getString("bill_no")).thenReturn("REC-260818-0001");
|
||||||
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
||||||
when(rs.getString("status")).thenReturn(status);
|
when(rs.getString("status")).thenReturn(status);
|
||||||
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
|
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
|
||||||
@@ -591,7 +591,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
|
|
||||||
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
||||||
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
||||||
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001");
|
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001");
|
||||||
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
|
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-76
@@ -1,76 +0,0 @@
|
|||||||
package com.baiyue.finance.repository;
|
|
||||||
|
|
||||||
import com.baiyue.finance.mapper.DocumentSequenceMapper;
|
|
||||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
||||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mybatis.spring.SqlSessionTemplate;
|
|
||||||
import org.springframework.aop.framework.ProxyFactory;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.datasource.*;
|
|
||||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
|
||||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.*;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
|
|
||||||
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
|
|
||||||
class DocumentSequenceRepositoryTest {
|
|
||||||
private static class Fixture {
|
|
||||||
final JdbcTemplate jdbc;
|
|
||||||
final DataSourceTransactionManager transactions;
|
|
||||||
final DocumentSequenceMapper mapper;
|
|
||||||
final DocumentSequenceRepository repository;
|
|
||||||
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
|
|
||||||
Fixture() throws Exception {
|
|
||||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
|
||||||
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
|
||||||
jdbc.execute("CREATE TABLE fin_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
|
|
||||||
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
|
|
||||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
|
||||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
|
|
||||||
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
|
|
||||||
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
|
||||||
repository=(DocumentSequenceRepository)proxy.getProxy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** 日期和前缀各自独立,重复分配持续递增。 */
|
|
||||||
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
|
|
||||||
var f=new Fixture();
|
|
||||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
|
||||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
|
||||||
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
|
|
||||||
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
|
|
||||||
}
|
|
||||||
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
|
|
||||||
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
|
|
||||||
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
|
|
||||||
try {
|
|
||||||
var work=new ArrayList<Callable<Long>>();
|
|
||||||
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
|
|
||||||
var values=new TreeSet<Long>();
|
|
||||||
for(var future:pool.invokeAll(work))values.add(future.get());
|
|
||||||
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
|
|
||||||
} finally {pool.shutdownNow();}
|
|
||||||
}
|
|
||||||
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
|
|
||||||
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
|
|
||||||
var f=new Fixture();
|
|
||||||
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
|
||||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
|
||||||
throw new IllegalStateException("业务回滚");
|
|
||||||
})).isInstanceOf(IllegalStateException.class);
|
|
||||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
|
||||||
}
|
|
||||||
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
|
|
||||||
@Test void connectionResultIsNeverCached() throws Exception {
|
|
||||||
var f=new Fixture();
|
|
||||||
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
|
||||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
|
||||||
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
|
||||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
|
||||||
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+57
-28
@@ -1,41 +1,70 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
import com.baiyue.finance.repository.DocumentSequenceRepository;
|
|
||||||
import com.baiyue.common.core.exception.BizException;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||||
import java.time.*;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
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 FinanceDocumentNumberGeneratorTest {
|
class FinanceDocumentNumberGeneratorTest {
|
||||||
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
|
private static final Clock CLOCK = Clock.fixed(
|
||||||
|
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||||
|
context.registerBean(JdbcTemplate.class, () -> jdbc);
|
||||||
|
context.register(FinanceDocumentNumberGenerator.class);
|
||||||
|
context.refresh();
|
||||||
|
|
||||||
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
|
|
||||||
@Test void springCreatesGeneratorWithRepository() {
|
|
||||||
try(var context=new AnnotationConfigApplicationContext()) {
|
|
||||||
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
|
|
||||||
context.register(FinanceDocumentNumberGenerator.class);context.refresh();
|
|
||||||
assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull();
|
assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
|
|
||||||
@Test void usesShanghaiDateAndIndependentSequenceKey() {
|
@Test
|
||||||
var sequences=mock(DocumentSequenceRepository.class);
|
void formatsShortDailySequenceForAllThreeDocuments() {
|
||||||
when(sequences.next("REC:20261020")).thenReturn(1L,2L,9999L);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK);
|
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L);
|
||||||
assertThat(numbers.next("REC")).isEqualTo("REC202610200001");
|
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||||
assertThat(numbers.next("REC")).isEqualTo("REC202610200002");
|
|
||||||
assertThat(numbers.next("REC")).isEqualTo("REC202610209999");
|
assertThat(generator.next("REC")).isEqualTo("REC-260818-0001");
|
||||||
verify(sequences,times(3)).next("REC:20261020");
|
assertThat(generator.next("AR")).isEqualTo("AR-260818-0002");
|
||||||
|
assertThat(generator.next("AP")).isEqualTo("AP-260818-0038");
|
||||||
|
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"),
|
||||||
|
eq("REC:20260818"));
|
||||||
}
|
}
|
||||||
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
|
|
||||||
@Test void rejectsUnknownTypeAndOverflow() {
|
@Test
|
||||||
var sequences=mock(DocumentSequenceRepository.class);
|
void rejectsUnsupportedTypeAndOverflow() {
|
||||||
var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
|
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||||
verifyNoInteractions(sequences);
|
assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class);
|
||||||
when(sequences.next("REC:20261020")).thenReturn(10000L);
|
|
||||||
assertThatThrownBy(()->numbers.next("REC")).isInstanceOf(BizException.class);
|
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
||||||
|
assertThatThrownBy(() -> generator.next("REC"))
|
||||||
|
.isInstanceOf(FinanceEventException.class)
|
||||||
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void vehicleCostsAndPaymentsUseIndependentSequenceKeys() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L);
|
||||||
|
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||||
|
assertThat(generator.next("VC")).isEqualTo("VC-260818-0001");
|
||||||
|
assertThat(generator.next("VP")).isEqualTo("VP-260818-0001");
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818"));
|
||||||
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
|
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
|
||||||
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
|
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
|
||||||
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
|
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
|
||||||
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联允许真实集装箱号暂缺;确认预装和创建 PDA task 前必须补齐非空柜号,不按 ISO 格式阻断;可在 `EDITING/LOADING` 首次补录,PDA 完成前再次校验;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
|
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
|
||||||
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
|
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
|
||||||
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
|
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
|
||||||
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
|
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` |
|
| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` |
|
||||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
||||||
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
|
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
|
||||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 |
|
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单号或已有非终态预约的订舱;创建及派车不要求订舱文件;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||||
@@ -79,9 +79,9 @@
|
|||||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||||
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
|
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
|
||||||
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港草稿可留空,放舱前必填且不得早于预计开船时间;关联柜次时带入柜级到港来源,供日历和默认到仓日期解析使用。
|
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||||
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||||
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
||||||
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
## 国内货柜履约装柜与 PDA 资源结算
|
## 国内货柜履约装柜与 PDA 资源结算
|
||||||
|
|
||||||
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺;关联、运输修改和提柜补录统一只规范化空白和大小写,不以 ISO 长度、结构、设备类别或校验位阻断,数据库不设置 ISO 格式 CHECK,仍遵守同订舱唯一及已有柜号一致性。确认预装、创建 PDA 任务和 PDA 完成只要求真实柜号非空,不再校验 ISO 格式;跨服务装柜、船节点和卸柜事件统一接受非 ISO 柜号,冻结事实仍要求柜号已规范化且与来源柜一致。运输纠错继续遵守原因、版本及 PDA 领取和物理作业冻结限制。确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||||
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
|
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
|
||||||
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
||||||
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||||
|
|||||||
+1
@@ -56,6 +56,7 @@ public final class ContainerCustomsDtos {
|
|||||||
String remark, List<ClearanceResultView> results) {}
|
String remark, List<ClearanceResultView> results) {}
|
||||||
|
|
||||||
public record DeclarationBatchRequest(@Min(0) int expectedVersion,
|
public record DeclarationBatchRequest(@Min(0) int expectedVersion,
|
||||||
|
@Size(max = 64) String batchNo,
|
||||||
@Size(max = 64) String customsEntryNo,
|
@Size(max = 64) String customsEntryNo,
|
||||||
@Size(max = 500) String remark) {}
|
@Size(max = 500) String remark) {}
|
||||||
|
|
||||||
|
|||||||
+22
-22
@@ -29,8 +29,6 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ContainerCustomsService {
|
public class ContainerCustomsService {
|
||||||
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
|
|
||||||
|
|
||||||
public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS";
|
public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS";
|
||||||
public static final String READY = "READY_TO_START";
|
public static final String READY = "READY_TO_START";
|
||||||
public static final String DECLARING = "DECLARING";
|
public static final String DECLARING = "DECLARING";
|
||||||
@@ -181,7 +179,6 @@ public class ContainerCustomsService {
|
|||||||
return platformView(planId);
|
return platformView(planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首次发送报关资料时生成 BR 批次号,重放沿用已有批次。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key,
|
public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key,
|
||||||
ActionRequest request) {
|
ActionRequest request) {
|
||||||
@@ -200,7 +197,7 @@ public class ContainerCustomsService {
|
|||||||
throw conflict("报关资料尚未全部冻结,不能发送给报关行");
|
throw conflict("报关资料尚未全部冻结,不能发送给报关行");
|
||||||
}
|
}
|
||||||
long submissionId = IdWorker.getId();
|
long submissionId = IdWorker.getId();
|
||||||
String batchNo = documentNumbers.next("BR");
|
String batchNo = brokerBatchNo(planId);
|
||||||
String snapshot = brokerSubmissionSnapshot(container.id());
|
String snapshot = brokerSubmissionSnapshot(container.id());
|
||||||
if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot,
|
if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot,
|
||||||
operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败");
|
operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败");
|
||||||
@@ -211,7 +208,6 @@ public class ContainerCustomsService {
|
|||||||
return platformView(planId);
|
return platformView(planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全部预录单经客户确认后发送报关行执行,保留版本与发送状态校验。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||||
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
|
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
|
||||||
@@ -222,8 +218,8 @@ public class ContainerCustomsService {
|
|||||||
ContainerRow container = mapper.lockContainer(planId);
|
ContainerRow container = mapper.lockContainer(planId);
|
||||||
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
|
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
|
||||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||||
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送预录单确认结果给报关行");
|
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行");
|
||||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有预录单未完成客户确认");
|
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
|
||||||
if (mapper.brokerSubmission(container.id()) == null) throw conflict("当前柜尚未完成首次发送报关行资料");
|
if (mapper.brokerSubmission(container.id()) == null) throw conflict("当前柜尚未完成首次发送报关行资料");
|
||||||
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
|
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
|
||||||
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
|
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
|
||||||
@@ -232,7 +228,6 @@ public class ContainerCustomsService {
|
|||||||
return platformView(planId);
|
return platformView(planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传预录单并保存文件版本,已发送报关行执行后仍禁止替换。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
||||||
int expectedVersion, MultipartFile file) {
|
int expectedVersion, MultipartFile file) {
|
||||||
@@ -248,7 +243,7 @@ public class ContainerCustomsService {
|
|||||||
throw conflict("当前客户资料不在报关中冻结版本");
|
throw conflict("当前客户资料不在报关中冻结版本");
|
||||||
}
|
}
|
||||||
if (mapper.brokerSubmittedAt(container.id()) != null) {
|
if (mapper.brokerSubmittedAt(container.id()) != null) {
|
||||||
throw conflict("客户确认的预录单已发送给报关行,不可再修改预录单");
|
throw conflict("客户确认回执已发送给报关行,不可再修改回执");
|
||||||
}
|
}
|
||||||
if (mapper.brokerSubmission(container.id()) == null) {
|
if (mapper.brokerSubmission(container.id()) == null) {
|
||||||
throw conflict("当前柜尚未完成首次发送报关行资料");
|
throw conflict("当前柜尚未完成首次发送报关行资料");
|
||||||
@@ -302,7 +297,6 @@ public class ContainerCustomsService {
|
|||||||
return merchantViewForDocument(tenantId, source);
|
return merchantViewForDocument(tenantId, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预录单确认并发送报关行执行后,接收真实报关完税文件。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
|
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
|
||||||
int expectedVersion, MultipartFile file) {
|
int expectedVersion, MultipartFile file) {
|
||||||
@@ -319,7 +313,7 @@ public class ContainerCustomsService {
|
|||||||
}
|
}
|
||||||
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
|
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
|
||||||
CompletionRow receipt = mapper.lockCompletion(documentId);
|
CompletionRow receipt = mapper.lockCompletion(documentId);
|
||||||
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户的预录单尚未确认");
|
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
|
||||||
FinalRow existing = mapper.lockFinalDocument(documentId);
|
FinalRow existing = mapper.lockFinalDocument(documentId);
|
||||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||||
long finalId;
|
long finalId;
|
||||||
@@ -362,24 +356,23 @@ public class ContainerCustomsService {
|
|||||||
return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList();
|
return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首次创建分配独立申报批次号,重复命令返回已创建批次。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName,
|
public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName,
|
||||||
String key, DeclarationBatchRequest request) {
|
String key, DeclarationBatchRequest request) {
|
||||||
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId;
|
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId;
|
||||||
String requestHash = hash(action, request.expectedVersion(), request.remark());
|
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark());
|
||||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId()));
|
if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId()));
|
||||||
ContainerRow container = mapper.lockContainer(planId);
|
ContainerRow container = mapper.lockContainer(planId);
|
||||||
if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次");
|
if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次");
|
||||||
if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次");
|
if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次");
|
||||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) {
|
if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) {
|
||||||
throw conflict("当前柜尚未完成退税资料、预录单确认和报关行执行");
|
throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行");
|
||||||
}
|
}
|
||||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||||
long batchId = IdWorker.getId();
|
long batchId = IdWorker.getId();
|
||||||
// 申报批次号由服务端分配,外部报关单号独立保存在 customsEntryNo。
|
String batchNo = trim(request.batchNo());
|
||||||
String batchNo = documentNumbers.next("DECL");
|
if (batchNo == null) batchNo = "DECL-" + batchId;
|
||||||
String snapshot = declarationSnapshot(container.id());
|
String snapshot = declarationSnapshot(container.id());
|
||||||
if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot,
|
if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot,
|
||||||
trim(request.remark())) != 1) throw conflict("正式申报批次创建失败");
|
trim(request.remark())) != 1) throw conflict("正式申报批次创建失败");
|
||||||
@@ -388,20 +381,20 @@ public class ContainerCustomsService {
|
|||||||
return declarationBatchView(mapper.declarationBatch(batchId));
|
return declarationBatchView(mapper.declarationBatch(batchId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提交只回填外部报关单号,内部批次号保持创建时的值。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName,
|
public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName,
|
||||||
String key, DeclarationBatchRequest request) {
|
String key, DeclarationBatchRequest request) {
|
||||||
String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId;
|
String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId;
|
||||||
String entryNo = trim(request.customsEntryNo());
|
String entryNo = trim(request.customsEntryNo());
|
||||||
if (entryNo == null) throw bad("正式申报必须回填报关单号");
|
if (entryNo == null) throw bad("正式申报必须回填报关单号");
|
||||||
String requestHash = hash(action, request.expectedVersion(), entryNo, request.remark());
|
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark());
|
||||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId));
|
if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId));
|
||||||
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
|
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
|
||||||
if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态");
|
if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态");
|
||||||
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
||||||
String batchNo = batch.batchNo();
|
String batchNo = trim(request.batchNo());
|
||||||
|
if (batchNo == null) batchNo = batch.batchNo();
|
||||||
if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName,
|
if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName,
|
||||||
trim(request.remark())) != 1) throw concurrent();
|
trim(request.remark())) != 1) throw concurrent();
|
||||||
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
||||||
@@ -508,6 +501,14 @@ public class ContainerCustomsService {
|
|||||||
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
|
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String brokerBatchNo(long planId) {
|
||||||
|
PlanRow plan = requiredPlan(planId);
|
||||||
|
String planNo = trim(plan.planNo());
|
||||||
|
if (planNo != null && planNo.matches("[A-Za-z0-9_-]{1,52}")) return "BR-" + planNo + "-01";
|
||||||
|
String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(Locale.ROOT);
|
||||||
|
return "BR-" + LocalDate.now() + "-" + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
private String brokerSubmissionSnapshot(long containerId) {
|
private String brokerSubmissionSnapshot(long containerId) {
|
||||||
List<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(document -> {
|
List<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(document -> {
|
||||||
List<ScopeRow> scopes = mapper.frozenScopes(document.id());
|
List<ScopeRow> scopes = mapper.frozenScopes(document.id());
|
||||||
@@ -539,15 +540,14 @@ public class ContainerCustomsService {
|
|||||||
row.operatorName(), row.createdAt())).toList();
|
row.operatorName(), row.createdAt())).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按既有客户范围与可见状态下载预录单,沿用文件真实名称。 */
|
|
||||||
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
|
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
|
||||||
DocumentRow source = requiredDocument(documentId);
|
DocumentRow source = requiredDocument(documentId);
|
||||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||||
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
|
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
|
||||||
throw notFound("预录单不存在");
|
throw notFound("报关行回执不存在");
|
||||||
}
|
}
|
||||||
CompletionRow completion = mapper.completion(documentId);
|
CompletionRow completion = mapper.completion(documentId);
|
||||||
if (completion == null) throw notFound("预录单不存在");
|
if (completion == null) throw notFound("报关行回执不存在");
|
||||||
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.baiyue.firstleg.domain;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** ISO 6346 真实集装箱号的唯一规范化与校验入口。 */
|
||||||
|
public final class Iso6346ContainerNumber {
|
||||||
|
private static final int[] LETTER_VALUES = {
|
||||||
|
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||||
|
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||||
|
};
|
||||||
|
|
||||||
|
private Iso6346ContainerNumber() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String require(String raw) {
|
||||||
|
String value = normalizeWhitespace(raw);
|
||||||
|
if (value == null || !value.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
int sum = 0;
|
||||||
|
for (int index = 0; index < 10; index++) {
|
||||||
|
char current = value.charAt(index);
|
||||||
|
int number = Character.isDigit(current)
|
||||||
|
? current - '0' : LETTER_VALUES[current - 'A'];
|
||||||
|
sum += number * (1 << index);
|
||||||
|
}
|
||||||
|
int expected = sum % 11 % 10;
|
||||||
|
if (value.charAt(10) - '0' != expected) throw invalid();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeWhitespace(String raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
StringBuilder value = new StringBuilder(raw.length());
|
||||||
|
raw.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||||
|
&& !Character.isSpaceChar(codePoint))
|
||||||
|
.forEach(value::appendCodePoint);
|
||||||
|
String normalized = value.toString().toUpperCase(Locale.ROOT);
|
||||||
|
return normalized.isEmpty() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BizException invalid() {
|
||||||
|
return new BizException(ResultCode.BAD_REQUEST,
|
||||||
|
"真实集装箱号必须符合ISO 6346结构、设备类别和校验位");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.baiyue.firstleg.domain;
|
|
||||||
|
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
|
||||||
import com.baiyue.common.core.exception.BizException;
|
|
||||||
import com.baiyue.common.event.ContainerNumbers;
|
|
||||||
|
|
||||||
/** 真实集装箱号录入与作业门禁,与跨服务事件使用同一规范化规则。 */
|
|
||||||
public final class RealContainerNumber {
|
|
||||||
private RealContainerNumber() { }
|
|
||||||
|
|
||||||
/** 保留柜号必填要求,不再检查 ISO 长度、设备类别和校验位。 */
|
|
||||||
public static String require(String raw) {
|
|
||||||
try {
|
|
||||||
return ContainerNumbers.normalizeRequired(raw);
|
|
||||||
} catch (IllegalArgumentException exception) {
|
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "真实集装箱号不能为空");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,7 @@ import jakarta.validation.constraints.*; import java.math.BigDecimal; import jav
|
|||||||
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
||||||
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||||
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
|
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
|
||||||
/** 已放舱订舱候选,预约不依赖订舱附件。 */
|
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
|
||||||
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName){}
|
|
||||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal payableAmount,@Pattern(regexp="RMB") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal payableAmount,@Pattern(regexp="RMB") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
||||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||||
|
|||||||
+1
-2
@@ -67,11 +67,10 @@ public class PlatformColoadGroupController {
|
|||||||
idempotencyKey, user.userId(), user.username()));
|
idempotencyKey, user.userId(), user.username()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按入仓码返回同柜组业务编号及当前有效成员,保留平台只读权限边界。 */
|
|
||||||
@GetMapping("/by-inbound-code/{inboundCode}")
|
@GetMapping("/by-inbound-code/{inboundCode}")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||||
@Operation(summary = "按入仓码查询同柜组业务摘要",
|
@Operation(summary = "按入仓码查询同柜组业务摘要",
|
||||||
description = "返回真实业务组号、业务状态、有效成员数及去重后的全部成员入仓码;不返回内部组ID。")
|
description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。")
|
||||||
public ApiResponse<BusinessSummary> byInboundCode(
|
public ApiResponse<BusinessSummary> byInboundCode(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
|
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ public final class ColoadGroupModels {
|
|||||||
|
|
||||||
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
|
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
|
||||||
public record BusinessSummary(
|
public record BusinessSummary(
|
||||||
String groupNo,
|
|
||||||
String status,
|
String status,
|
||||||
int memberCount,
|
int memberCount,
|
||||||
List<BusinessMember> members
|
List<BusinessMember> members
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ public interface ColoadGroupMapper {
|
|||||||
@Param("inboundId") long inboundId);
|
@Param("inboundId") long inboundId);
|
||||||
|
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,group_row.group_status groupStatus
|
SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus
|
||||||
FROM fl_inbound_order requested
|
FROM fl_inbound_order requested
|
||||||
JOIN fl_coload_group_member member
|
JOIN fl_coload_group_member member
|
||||||
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
|
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
|
||||||
|
|||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
package com.baiyue.firstleg.loading.coload.persistence;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** 平台同柜组摘要的只读数据访问,保留原入仓片段和组状态范围。 */
|
|
||||||
@Repository
|
|
||||||
public class ColoadGroupQueryRepository {
|
|
||||||
private final ColoadGroupMapper mapper;
|
|
||||||
/** 注入同服务持久化入口。 */
|
|
||||||
public ColoadGroupQueryRepository(ColoadGroupMapper mapper) { this.mapper = mapper; }
|
|
||||||
/** 按业务入仓码查询当前组及其真实业务组号。 */
|
|
||||||
public List<ColoadPersistenceRows.InboundGroupLookupRow> groupsByInboundCode(String code) {
|
|
||||||
return mapper.groupsByInboundCode(code);
|
|
||||||
}
|
|
||||||
/** 读取被冻结的成员,交由服务核对缺失和重复片段。 */
|
|
||||||
public List<ColoadPersistenceRows.MemberCodeRow> effectiveMemberCodes(long groupId) {
|
|
||||||
return mapper.effectiveMemberCodes(groupId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1
@@ -59,7 +59,6 @@ public final class ColoadPersistenceRows {
|
|||||||
@Setter
|
@Setter
|
||||||
public static class InboundGroupLookupRow {
|
public static class InboundGroupLookupRow {
|
||||||
private Long groupId;
|
private Long groupId;
|
||||||
private String groupNo;
|
|
||||||
private String groupStatus;
|
private String groupStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-9
@@ -38,8 +38,6 @@ import java.util.Set;
|
|||||||
/** 同柜不可拆组的唯一状态机与平台编排服务。 */
|
/** 同柜不可拆组的唯一状态机与平台编排服务。 */
|
||||||
@Service
|
@Service
|
||||||
public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||||
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
|
|
||||||
|
|
||||||
private static final Set<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
|
private static final Set<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
|
||||||
private static final Set<String> PRE_LOADING_GROUP_STATUSES = Set.of(
|
private static final Set<String> PRE_LOADING_GROUP_STATUSES = Set.of(
|
||||||
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||||
@@ -47,8 +45,6 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ColoadGroupMapper mapper;
|
private ColoadGroupMapper mapper;
|
||||||
@Resource
|
|
||||||
private com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository queryRepository;
|
|
||||||
@Resource private PendingReviewOrderClient pendingReviewOrders;
|
@Resource private PendingReviewOrderClient pendingReviewOrders;
|
||||||
@Resource private OrderCargoClient orderCargoClient;
|
@Resource private OrderCargoClient orderCargoClient;
|
||||||
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
||||||
@@ -68,7 +64,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
validateSameRoute(candidates);
|
validateSameRoute(candidates);
|
||||||
Long lockedPlanId = validateAllocationState(candidates);
|
Long lockedPlanId = validateAllocationState(candidates);
|
||||||
long groupId = IdWorker.getId();
|
long groupId = IdWorker.getId();
|
||||||
String groupNo = documentNumbers.next("CLG");
|
String groupNo = "CLG-" + groupId;
|
||||||
InboundCandidateRow first = candidates.get(0);
|
InboundCandidateRow first = candidates.get(0);
|
||||||
mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(),
|
mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(),
|
||||||
operatorId, trim(operatorName));
|
operatorId, trim(operatorName));
|
||||||
@@ -120,11 +116,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
return requiredSnapshot(groupId);
|
return requiredSnapshot(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public BusinessSummary queryByInboundCode(String inboundCode) {
|
public BusinessSummary queryByInboundCode(String inboundCode) {
|
||||||
String code = normalizedInboundCode(inboundCode);
|
String code = normalizedInboundCode(inboundCode);
|
||||||
var groups = queryRepository.groupsByInboundCode(code).stream()
|
var groups = mapper.groupsByInboundCode(code).stream()
|
||||||
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
|
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
|
||||||
.toList();
|
.toList();
|
||||||
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
|
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
|
||||||
@@ -134,7 +129,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
|
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
|
||||||
long groupId = groups.get(0).getGroupId();
|
long groupId = groups.get(0).getGroupId();
|
||||||
String status = groups.get(0).getGroupStatus();
|
String status = groups.get(0).getGroupStatus();
|
||||||
var memberRows = queryRepository.effectiveMemberCodes(groupId);
|
var memberRows = mapper.effectiveMemberCodes(groupId);
|
||||||
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
|
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
|
||||||
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|
||||||
|| trim(row.getInboundCode()) == null)) {
|
|| trim(row.getInboundCode()) == null)) {
|
||||||
@@ -145,7 +140,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
throw conflict("同柜不可拆组存在重复入仓片段业务号");
|
throw conflict("同柜不可拆组存在重复入仓片段业务号");
|
||||||
}
|
}
|
||||||
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
|
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
|
||||||
return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members);
|
return new BusinessSummary(status, members.size(), members);
|
||||||
}
|
}
|
||||||
|
|
||||||
private long requiredCurrentGroupId(String inboundCode) {
|
private long requiredCurrentGroupId(String inboundCode) {
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package com.baiyue.firstleg.mapper;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Options;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
|
|
||||||
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
|
|
||||||
@Mapper
|
|
||||||
public interface DocumentSequenceMapper {
|
|
||||||
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
|
|
||||||
@Insert("INSERT INTO fl_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
|
|
||||||
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
|
|
||||||
int allocate(@Param("key") String key);
|
|
||||||
|
|
||||||
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
|
|
||||||
@Select("SELECT LAST_INSERT_ID()")
|
|
||||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
|
||||||
Long allocatedValue();
|
|
||||||
}
|
|
||||||
+3
-29
@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|||||||
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||||
import org.apache.ibatis.annotations.Insert;
|
import org.apache.ibatis.annotations.Insert;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Options;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.apache.ibatis.annotations.Update;
|
import org.apache.ibatis.annotations.Update;
|
||||||
@@ -56,34 +55,28 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
|
|||||||
int claim(@Param("id") long id, @Param("worker") String worker,
|
int claim(@Param("id") long id, @Param("worker") String worker,
|
||||||
@Param("leaseUntil") LocalDateTime leaseUntil);
|
@Param("leaseUntil") LocalDateTime leaseUntil);
|
||||||
|
|
||||||
/** 只有尚未过期的当前所有者可以续租,过期执行者必须重新领取。 */
|
|
||||||
@Update("""
|
@Update("""
|
||||||
UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3)
|
UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3)
|
||||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
|
||||||
""")
|
""")
|
||||||
int renew(@Param("id") long id, @Param("worker") String worker,
|
int renew(@Param("id") long id, @Param("worker") String worker,
|
||||||
@Param("leaseUntil") LocalDateTime leaseUntil);
|
@Param("leaseUntil") LocalDateTime leaseUntil);
|
||||||
|
|
||||||
/** 完成落库必须仍持有有效租约,拒绝过期或已易主的旧执行者。 */
|
|
||||||
@Update("""
|
@Update("""
|
||||||
UPDATE fl_loading_completion_command
|
UPDATE fl_loading_completion_command
|
||||||
SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
|
SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
|
||||||
lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL,
|
lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL,
|
||||||
failure_request_id=NULL,failure_trace_id=NULL
|
failure_request_id=NULL,failure_trace_id=NULL
|
||||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
|
||||||
""")
|
""")
|
||||||
int complete(@Param("id") long id, @Param("worker") String worker);
|
int complete(@Param("id") long id, @Param("worker") String worker);
|
||||||
|
|
||||||
/** 失败回执也受当前有效租约保护,不能覆盖新执行者的状态。 */
|
|
||||||
@Update("""
|
@Update("""
|
||||||
UPDATE fl_loading_completion_command
|
UPDATE fl_loading_completion_command
|
||||||
SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3),
|
SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3),
|
||||||
lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code},
|
lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code},
|
||||||
failure_request_id=#{requestId},failure_trace_id=#{traceId}
|
failure_request_id=#{requestId},failure_trace_id=#{traceId}
|
||||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
|
||||||
""")
|
""")
|
||||||
int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
|
int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
|
||||||
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
|
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
|
||||||
@@ -104,32 +97,13 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
|
|||||||
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
|
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
|
||||||
@Param("inboundId") long inboundId);
|
@Param("inboundId") long inboundId);
|
||||||
|
|
||||||
/** 检查点事务锁住当前有效租约,与并发重新领取互斥。 */
|
|
||||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
|
||||||
@Select("""
|
|
||||||
SELECT id FROM fl_loading_completion_command
|
|
||||||
WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
|
||||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
|
||||||
FOR UPDATE
|
|
||||||
""")
|
|
||||||
Long lockCheckpointLease(@Param("commandId") long commandId, @Param("worker") String worker);
|
|
||||||
|
|
||||||
/** 在已锁定的命令行上重验有效租约;检查点只能前进,不能被迟到写入降级。 */
|
|
||||||
@Insert("""
|
@Insert("""
|
||||||
INSERT INTO fl_loading_completion_checkpoint
|
INSERT INTO fl_loading_completion_checkpoint
|
||||||
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
|
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
|
||||||
SELECT id,#{tenantId},#{inboundId},#{sequence},#{stage}
|
VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage})
|
||||||
FROM fl_loading_completion_command
|
ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3)
|
||||||
WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
|
||||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
|
||||||
ON DUPLICATE KEY UPDATE checkpoint_stage=CASE
|
|
||||||
WHEN checkpoint_stage='INBOUND_COMPLETED' OR #{stage}='INBOUND_COMPLETED'
|
|
||||||
THEN 'INBOUND_COMPLETED'
|
|
||||||
WHEN checkpoint_stage='SETTLED' OR #{stage}='SETTLED' THEN 'SETTLED'
|
|
||||||
ELSE 'READY' END,updated_at=CURRENT_TIMESTAMP(3)
|
|
||||||
""")
|
""")
|
||||||
int checkpointCompleted(@Param("commandId") long commandId, @Param("worker") String worker,
|
int checkpointCompleted(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
|
||||||
@Param("tenantId") long tenantId,
|
|
||||||
@Param("inboundId") long inboundId, @Param("sequence") int sequence,
|
@Param("inboundId") long inboundId, @Param("sequence") int sequence,
|
||||||
@Param("stage") String stage);
|
@Param("stage") String stage);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -64,13 +64,13 @@ public interface MerchantWorkbenchFilterMapper {
|
|||||||
ORDER BY i.order_id
|
ORDER BY i.order_id
|
||||||
""")
|
""")
|
||||||
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
||||||
/** 仅按本客户实际装柜片段读取进度;清关资料保存后立即显示已清关。 */
|
/** Only physically loaded fragments can expose container milestones to the customer. */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
|
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
|
||||||
WHEN EXISTS (SELECT 1 FROM fl_container_document d
|
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
|
||||||
WHERE d.container_booking_id=owned.container_booking_id
|
WHERE n.container_booking_id=owned.container_booking_id
|
||||||
AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL) THEN 3
|
AND n.standard_node='PICKUP_READY' AND n.actual_at IS NOT NULL) THEN 3
|
||||||
WHEN EXISTS (SELECT 1 FROM fl_customs_container c
|
WHEN EXISTS (SELECT 1 FROM fl_customs_container c
|
||||||
WHERE c.loading_plan_id=owned.loading_plan_id AND c.status='COMPLETED') THEN 2
|
WHERE c.loading_plan_id=owned.loading_plan_id AND c.status='COMPLETED') THEN 2
|
||||||
ELSE 1 END AS stage
|
ELSE 1 END AS stage
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
package com.baiyue.firstleg.mapper;
|
package com.baiyue.firstleg.mapper;
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
|
||||||
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
|
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
|
||||||
/** 查询已放舱且有提单号的可预约订舱,不依赖附件。 */
|
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT o.id oceanBookingId,o.booking_no oceanBookingNo,o.bill_of_lading_no billOfLadingNo,
|
SELECT o.id oceanBookingId,o.booking_no oceanBookingNo,o.bill_of_lading_no billOfLadingNo,
|
||||||
o.warehouse_id warehouseId,o.warehouse_name warehouseName,o.channel_code channelCode,
|
o.warehouse_id warehouseId,o.warehouse_name warehouseName,o.channel_code channelCode,
|
||||||
o.channel_name channelName
|
o.channel_name channelName,TRUE bookingDocumentReady
|
||||||
FROM fl_ocean_booking o
|
FROM fl_ocean_booking o
|
||||||
WHERE o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
WHERE EXISTS (SELECT 1 FROM fl_ocean_booking_document d WHERE d.ocean_booking_id=o.id
|
||||||
|
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
|
||||||
|
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
||||||
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
|
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
|
||||||
AND t.status NOT IN ('CANCELLED','EXCEPTION'))
|
AND t.status NOT IN ('CANCELLED','EXCEPTION'))
|
||||||
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
|
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||||
|
|||||||
-22
@@ -126,28 +126,6 @@ public class PlatformOceanBookingController {
|
|||||||
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
|
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
|
||||||
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
|
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||||
/** 放舱后单独维护预计到港,其他运输资料仍冻结。 */
|
|
||||||
@PutMapping("/{oceanBookingId}/estimated-arrival")
|
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
|
||||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ESTIMATED_ARRIVAL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="维护订舱预计到港")
|
|
||||||
public ApiResponse<Detail> updateEstimatedArrival(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
|
|
||||||
@Valid @RequestBody EstimatedArrivalRequest request) {
|
|
||||||
FirstlegScopeGuard.platform(user);
|
|
||||||
return ApiResponse.success(service.updateEstimatedArrival(oceanBookingId, user.userId(), user.username(), key, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
|
|
||||||
@PutMapping("/{oceanBookingId}/actual-cargo")
|
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
|
||||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ACTUAL_CARGO", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="补录订舱实际货量")
|
|
||||||
public ApiResponse<Detail> updateActualCargo(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
|
|
||||||
@Valid @RequestBody ActualCargoRequest request) {
|
|
||||||
FirstlegScopeGuard.platform(user);
|
|
||||||
return ApiResponse.success(service.updateActualCargo(oceanBookingId, user.userId(), user.username(), key, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')")
|
@DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')")
|
||||||
@BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿")
|
@BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿")
|
||||||
public ApiResponse<Void> deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); }
|
public ApiResponse<Void> deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); }
|
||||||
|
|||||||
@@ -45,12 +45,10 @@ public final class OceanBookingDtos {
|
|||||||
String customerName, String warehouseName, String channelName) {}
|
String customerName, String warehouseName, String channelName) {}
|
||||||
|
|
||||||
public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
|
public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
|
||||||
@NotNull @Size(max = 255) String partyName,
|
@NotBlank @Size(max = 255) String partyName,
|
||||||
@Size(max = 1000) String address,
|
@Size(max = 1000) String address,
|
||||||
@Size(max = 128) String contactName,
|
@Size(max = 128) String contactName,
|
||||||
@Size(max = 64) String contactPhone,
|
@Size(max = 64) String contactPhone) {}
|
||||||
@jakarta.validation.constraints.Email @Size(max = 255) String contactEmail,
|
|
||||||
@Size(max = 128) String taxNo) {}
|
|
||||||
|
|
||||||
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
|
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
|
||||||
@NotBlank @Size(max = 32) String containerKind,
|
@NotBlank @Size(max = 32) String containerKind,
|
||||||
@@ -78,35 +76,7 @@ public final class OceanBookingDtos {
|
|||||||
LocalDateTime estimatedArrivalAt,
|
LocalDateTime estimatedArrivalAt,
|
||||||
@Size(max = 1000) String remark,
|
@Size(max = 1000) String remark,
|
||||||
@Valid @Size(max = 3) List<PartyRequest> parties,
|
@Valid @Size(max = 3) List<PartyRequest> parties,
|
||||||
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements,
|
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
|
||||||
@Size(max = 64) String freightTerm,
|
|
||||||
@Size(max = 64) String serviceTerm,
|
|
||||||
@Pattern(regexp = "CARTONS|PCS|PALLETS") String packageUnit,
|
|
||||||
@Size(max = 1000) String cargoDescription,
|
|
||||||
@Size(max = 1000) String marksAndNos,
|
|
||||||
@Min(1) @Max(999999999) Integer declaredPackageCount,
|
|
||||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
|
||||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredGrossWeightKg,
|
|
||||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
|
||||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredVolumeCbm,
|
|
||||||
@Min(1) @Max(999999999) Integer actualPackageCount,
|
|
||||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
|
||||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
|
|
||||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
|
||||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
|
|
||||||
|
|
||||||
/** 放舱后仅维护预计到港,不接收其他运输资料。 */
|
|
||||||
public record EstimatedArrivalRequest(@NotNull @Min(0) Integer expectedVersion,
|
|
||||||
@NotNull LocalDateTime estimatedArrivalAt) {}
|
|
||||||
|
|
||||||
/** 放舱后的受控实货量补录,不接收运输资料。 */
|
|
||||||
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
|
|
||||||
@Min(1) @Max(999999999) Integer actualPackageCount,
|
|
||||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
|
||||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
|
|
||||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
|
||||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm,
|
|
||||||
@NotBlank @Size(max = 1000) String reason) {}
|
|
||||||
|
|
||||||
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
|
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||||
@Valid @NotNull DraftData data) {}
|
@Valid @NotNull DraftData data) {}
|
||||||
|
|||||||
-11
@@ -40,17 +40,6 @@ public class OceanBookingEntity {
|
|||||||
private LocalDateTime estimatedSailingAt;
|
private LocalDateTime estimatedSailingAt;
|
||||||
private LocalDateTime estimatedArrivalAt;
|
private LocalDateTime estimatedArrivalAt;
|
||||||
private String remark;
|
private String remark;
|
||||||
private String freightTerm;
|
|
||||||
private String serviceTerm;
|
|
||||||
private String packageUnit;
|
|
||||||
private String cargoDescription;
|
|
||||||
private String marksAndNos;
|
|
||||||
private Integer declaredPackageCount;
|
|
||||||
private java.math.BigDecimal declaredGrossWeightKg;
|
|
||||||
private java.math.BigDecimal declaredVolumeCbm;
|
|
||||||
private Integer actualPackageCount;
|
|
||||||
private java.math.BigDecimal actualGrossWeightKg;
|
|
||||||
private java.math.BigDecimal actualVolumeCbm;
|
|
||||||
private Long createdBy;
|
private Long createdBy;
|
||||||
private String createdByName;
|
private String createdByName;
|
||||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||||
|
|||||||
-2
@@ -17,6 +17,4 @@ public class OceanBookingPartyEntity {
|
|||||||
private String address;
|
private String address;
|
||||||
private String contactName;
|
private String contactName;
|
||||||
private String contactPhone;
|
private String contactPhone;
|
||||||
private String contactEmail;
|
|
||||||
private String taxNo;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-15
@@ -25,22 +25,8 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
|||||||
""")
|
""")
|
||||||
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
|
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
|
||||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||||
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||||
/** 已放舱预计到港窄更新,保留状态、版本和操作审计。 */
|
|
||||||
@Update("UPDATE fl_ocean_booking SET estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND status='RELEASED' AND version=#{version}")
|
|
||||||
int updateEstimatedArrival(@Param("id") long id, @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt,
|
|
||||||
@Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
|
||||||
|
|
||||||
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
|
|
||||||
@Update("""
|
|
||||||
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
|
|
||||||
actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},
|
|
||||||
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
|
||||||
WHERE id=#{value.id} AND status='RELEASED' AND version=#{version}
|
|
||||||
""")
|
|
||||||
int updateActualCargo(@Param("value") OceanBookingEntity value, @Param("version") int version,
|
|
||||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
|
||||||
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||||
int deleteDraft(@Param("id") long id, @Param("version") int version);
|
int deleteDraft(@Param("id") long id, @Param("version") int version);
|
||||||
@Select("""
|
@Select("""
|
||||||
|
|||||||
-9
@@ -54,15 +54,6 @@ public class OceanBookingRepository {
|
|||||||
public int touchDraft(long id, int version, long operatorId, String operatorName) {
|
public int touchDraft(long id, int version, long operatorId, String operatorName) {
|
||||||
return bookings.touchDraft(id, version, operatorId, operatorName);
|
return bookings.touchDraft(id, version, operatorId, operatorName);
|
||||||
}
|
}
|
||||||
/** 按订舱版本仅更新已放舱预计到港,不重写其他运输或货量资料。 */
|
|
||||||
public int updateEstimatedArrival(long id, java.time.LocalDateTime estimatedArrivalAt, int version, long operatorId, String operatorName) {
|
|
||||||
return bookings.updateEstimatedArrival(id, estimatedArrivalAt, version, operatorId, operatorName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
|
|
||||||
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
|
|
||||||
return bookings.updateActualCargo(value, version, operatorId, operatorName);
|
|
||||||
}
|
|
||||||
public int deleteDraft(long id, int version) {
|
public int deleteDraft(long id, int version) {
|
||||||
return bookings.deleteDraft(id, version);
|
return bookings.deleteDraft(id, version);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-51
@@ -46,7 +46,7 @@ public class OceanBookingPrintService {
|
|||||||
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
|
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
|
||||||
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
|
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
|
||||||
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
|
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
|
||||||
fields(template, detail), List.of(cargo(template, booking)))));
|
fields(template, detail), List.of(emptyCargo()))));
|
||||||
try {
|
try {
|
||||||
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
|
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
|
||||||
MediaType contentType = response.getHeaders().getContentType();
|
MediaType contentType = response.getHeaders().getContentType();
|
||||||
@@ -75,21 +75,14 @@ public class OceanBookingPrintService {
|
|||||||
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
|
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
|
||||||
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
|
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
|
||||||
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
|
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
|
||||||
fields.put("serviceTerm", text(booking.getServiceTerm()));
|
fields.put("serviceTerm", "");
|
||||||
fields.put("freightTerm", text(booking.getFreightTerm()));
|
fields.put("freightTerm", "");
|
||||||
Integer count = SHIPPING_INSTRUCTION.equals(template) ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
|
fields.put("totalPackagesInWords", "");
|
||||||
fields.put("totalPackagesInWords", packageWords(count, booking.getPackageUnit()));
|
|
||||||
if (SHIPPING_INSTRUCTION.equals(template)) {
|
if (SHIPPING_INSTRUCTION.equals(template)) {
|
||||||
fields.put("toCompany", text(booking.getBookingAgentName()));
|
fields.put("toCompany", text(booking.getBookingAgentName()));
|
||||||
fields.put("jobNumber", text(booking.getBookingNo()));
|
fields.put("jobNumber", text(booking.getBookingNo()));
|
||||||
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
|
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
|
||||||
fields.put("cargoQuantity", detail.containerRequirements().stream()
|
fields.put("cargoQuantity", "");
|
||||||
.map(value -> value.getQuantity() + "*" + value.getContainerType()).collect(Collectors.joining(" + ")));
|
|
||||||
OceanBookingPartyEntity shipper = parties.get("SHIPPER");
|
|
||||||
fields.put("picName", shipper == null ? "" : text(shipper.getContactName()));
|
|
||||||
fields.put("picTel", shipper == null ? "" : text(shipper.getContactPhone()));
|
|
||||||
fields.put("apName", text(booking.getBookingAgentName()));
|
|
||||||
fields.put("printDate", java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).toString());
|
|
||||||
fields.put("carrier", text(booking.getShippingCompanyName()));
|
fields.put("carrier", text(booking.getShippingCompanyName()));
|
||||||
fields.put("bookingAgent", text(booking.getBookingAgentName()));
|
fields.put("bookingAgent", text(booking.getBookingAgentName()));
|
||||||
fields.put("remark", text(booking.getRemark()));
|
fields.put("remark", text(booking.getRemark()));
|
||||||
@@ -105,43 +98,8 @@ public class OceanBookingPrintService {
|
|||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 两类单据严格分别读取委、实数据,不以柜型容量或预报量代替实装量。 */
|
private static PrintPdfClient.CargoRow emptyCargo() {
|
||||||
private static PrintPdfClient.CargoRow cargo(String template, OceanBookingEntity booking) {
|
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
|
||||||
boolean declared = SHIPPING_INSTRUCTION.equals(template);
|
|
||||||
Integer count = declared ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
|
|
||||||
BigDecimal weight = declared ? booking.getDeclaredGrossWeightKg() : booking.getActualGrossWeightKg();
|
|
||||||
BigDecimal volume = declared ? booking.getDeclaredVolumeCbm() : booking.getActualVolumeCbm();
|
|
||||||
return new PrintPdfClient.CargoRow(display(booking.getMarksAndNos()), count == null ? "—" : count.toString(),
|
|
||||||
display(booking.getPackageUnit()), display(booking.getCargoDescription()),
|
|
||||||
weight == null ? "—" : weight.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " KGS",
|
|
||||||
volume == null ? "—" : volume.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " CBM");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打印空字段使用统一占位符。 */
|
|
||||||
private static String display(String value) { return text(value).isEmpty() ? "—" : text(value); }
|
|
||||||
|
|
||||||
/** 件数大写跟随所选数据和包装单位生成,未录入时明确留空。 */
|
|
||||||
private static String packageWords(Integer count, String unit) {
|
|
||||||
if (count == null || !org.springframework.util.StringUtils.hasText(unit)) return "";
|
|
||||||
String packaging = count == 1 ? switch (unit) {
|
|
||||||
case "CARTONS" -> "CARTON";
|
|
||||||
case "PALLETS" -> "PALLET";
|
|
||||||
case "PCS" -> "PIECE";
|
|
||||||
default -> unit;
|
|
||||||
} : ("PCS".equals(unit) ? "PIECES" : unit);
|
|
||||||
return "SAY " + words(count) + " " + packaging + " ONLY";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将受件数范围约束的正整数转换为英文数量。 */
|
|
||||||
private static String words(int value) {
|
|
||||||
String[] small = {"ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN",
|
|
||||||
"ELEVEN", "TWELVE", "THIRTEEN", "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN", "EIGHTEEN", "NINETEEN"};
|
|
||||||
String[] tens = {"", "", "TWENTY", "THIRTY", "FORTY", "FIFTY", "SIXTY", "SEVENTY", "EIGHTY", "NINETY"};
|
|
||||||
if (value < 20) return small[value];
|
|
||||||
if (value < 100) return tens[value / 10] + (value % 10 == 0 ? "" : " " + words(value % 10));
|
|
||||||
int divisor = value >= 1000000 ? 1000000 : value >= 1000 ? 1000 : 100;
|
|
||||||
String scale = divisor == 1000000 ? "MILLION" : divisor == 1000 ? "THOUSAND" : "HUNDRED";
|
|
||||||
return words(value / divisor) + " " + scale + (value % divisor == 0 ? "" : " " + words(value % divisor));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
|
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
|
||||||
@@ -152,8 +110,7 @@ public class OceanBookingPrintService {
|
|||||||
private static String party(OceanBookingPartyEntity value) {
|
private static String party(OceanBookingPartyEntity value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
return List.of(text(value.getPartyName()), text(value.getAddress()),
|
return List.of(text(value.getPartyName()), text(value.getAddress()),
|
||||||
join(value.getContactName(), value.getContactPhone()), text(value.getContactEmail()),
|
join(value.getContactName(), value.getContactPhone())).stream()
|
||||||
text(value.getTaxNo()).isEmpty() ? "" : "TAX NO: " + text(value.getTaxNo())).stream()
|
|
||||||
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
|
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-64
@@ -51,6 +51,7 @@ public class OceanBookingService {
|
|||||||
@Resource private WarehouseMasterDataClient warehouses;
|
@Resource private WarehouseMasterDataClient warehouses;
|
||||||
@Resource private WarehousePartnerClient partners;
|
@Resource private WarehousePartnerClient partners;
|
||||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
|
@Resource private OceanBookingDocumentService documents;
|
||||||
@Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
|
@Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
|
||||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||||
@@ -144,44 +145,6 @@ public class OceanBookingService {
|
|||||||
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
|
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
|
||||||
return complete(id, operatorId, action, key, detail(after));
|
return complete(id, operatorId, action, key, detail(after));
|
||||||
}
|
}
|
||||||
/** 放舱后单独维护预计到港,按版本和幂等键保存并审计前后快照。 */
|
|
||||||
@Transactional
|
|
||||||
public Detail updateEstimatedArrival(long id, long operatorId, String name, String key, EstimatedArrivalRequest request) {
|
|
||||||
String action = "UPDATE_ESTIMATED_ARRIVAL";
|
|
||||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
|
||||||
if (replay != null) return replay;
|
|
||||||
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
|
|
||||||
requireStatus(before, OceanBookingStatus.RELEASED);
|
|
||||||
if (request.estimatedArrivalAt() == null) throw badRequest("预计到港时间不能为空");
|
|
||||||
if (before.getEstimatedSailingAt() != null && request.estimatedArrivalAt().isBefore(before.getEstimatedSailingAt())) {
|
|
||||||
throw badRequest("预计到港时间不能早于预计开船时间");
|
|
||||||
}
|
|
||||||
if (repository.updateEstimatedArrival(id, request.estimatedArrivalAt(), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
|
||||||
OceanBookingEntity saved = required(id);
|
|
||||||
addHistory(id, before.getStatus(), saved.getStatus(), action, "维护预计到港", before, saved, operatorId, name);
|
|
||||||
return complete(id, operatorId, action, key, detail(saved));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
|
|
||||||
@Transactional
|
|
||||||
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
|
|
||||||
String action = "UPDATE_ACTUAL_CARGO";
|
|
||||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
|
||||||
if (replay != null) return replay;
|
|
||||||
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
|
|
||||||
requireStatus(before, OceanBookingStatus.RELEASED);
|
|
||||||
if (!StringUtils.hasText(request.reason())) throw badRequest("请填写实货量补录或修正原因");
|
|
||||||
validateCargo(request.actualPackageCount(), request.actualGrossWeightKg(), request.actualVolumeCbm());
|
|
||||||
OceanBookingEntity after = objectMapper.convertValue(before, OceanBookingEntity.class);
|
|
||||||
after.setActualPackageCount(request.actualPackageCount());
|
|
||||||
after.setActualGrossWeightKg(request.actualGrossWeightKg());
|
|
||||||
after.setActualVolumeCbm(request.actualVolumeCbm());
|
|
||||||
if (repository.updateActualCargo(after, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
|
||||||
OceanBookingEntity saved = required(id);
|
|
||||||
addHistory(id, before.getStatus(), saved.getStatus(), action, request.reason(), before, saved, operatorId, name);
|
|
||||||
return complete(id, operatorId, action, key, detail(saved));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
|
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||||
String action = "DELETE_DRAFT";
|
String action = "DELETE_DRAFT";
|
||||||
@@ -193,7 +156,6 @@ public class OceanBookingService {
|
|||||||
complete(null, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
|
complete(null, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验完整订舱资料后放舱,不以附件上传作为前置条件。 */
|
|
||||||
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
|
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
|
||||||
String action = "RELEASE";
|
String action = "RELEASE";
|
||||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||||
@@ -202,6 +164,7 @@ public class OceanBookingService {
|
|||||||
requireStatus(before, OceanBookingStatus.DRAFT);
|
requireStatus(before, OceanBookingStatus.DRAFT);
|
||||||
validateRelease(before, repository.parties(id), repository.requirements(id));
|
validateRelease(before, repository.parties(id), repository.requirements(id));
|
||||||
validateMasterData(before);
|
validateMasterData(before);
|
||||||
|
documents.requireActiveValidDocument(before);
|
||||||
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
|
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
|
||||||
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||||
OceanBookingEntity after = required(id);
|
OceanBookingEntity after = required(id);
|
||||||
@@ -246,18 +209,7 @@ public class OceanBookingService {
|
|||||||
private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
|
private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
|
||||||
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
|
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
|
||||||
|
|
||||||
/** 未采集使用空值;已填写的件数、重量和体积必须为正数且不超出存储精度。 */
|
|
||||||
private static void validateCargo(Integer count, java.math.BigDecimal weight, java.math.BigDecimal volume) {
|
|
||||||
if (count != null && (count < 1 || count > 999999999)) throw badRequest("货物件数必须为1到999999999的整数");
|
|
||||||
for (java.math.BigDecimal number : new java.math.BigDecimal[] {weight, volume}) {
|
|
||||||
if (number != null && (number.signum() <= 0 || number.scale() > 3
|
|
||||||
|| number.precision() - number.scale() > 12)) throw badRequest("货物重量和体积必须为正数,最多12位整数和3位小数");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateDraftStructure(DraftData data) {
|
private void validateDraftStructure(DraftData data) {
|
||||||
validateCargo(data.declaredPackageCount(), data.declaredGrossWeightKg(), data.declaredVolumeCbm());
|
|
||||||
validateCargo(data.actualPackageCount(), data.actualGrossWeightKg(), data.actualVolumeCbm());
|
|
||||||
boolean hasCustomerId = data.customerId() != null;
|
boolean hasCustomerId = data.customerId() != null;
|
||||||
boolean hasCustomerName = StringUtils.hasText(data.customerName());
|
boolean hasCustomerName = StringUtils.hasText(data.customerName());
|
||||||
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
|
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
|
||||||
@@ -279,7 +231,6 @@ public class OceanBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||||
/** 放舱要求预计到港,供关联柜次的到港日历及默认到仓日期使用。 */
|
|
||||||
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
|
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
|
||||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||||
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||||
@@ -291,7 +242,6 @@ public class OceanBookingService {
|
|||||||
|| !StringUtils.hasText(booking.getPlaceOfDelivery())
|
|| !StringUtils.hasText(booking.getPlaceOfDelivery())
|
||||||
|| !StringUtils.hasText(booking.getCarrierCode())
|
|| !StringUtils.hasText(booking.getCarrierCode())
|
||||||
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|
||||||
|| (parties != null && parties.stream().anyMatch(party -> !StringUtils.hasText(party.getPartyName())))
|
|
||||||
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
|
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
|
||||||
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
|
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
|
||||||
}
|
}
|
||||||
@@ -348,17 +298,6 @@ public class OceanBookingService {
|
|||||||
v.setPlaceOfDelivery(trim(d.placeOfDelivery()));
|
v.setPlaceOfDelivery(trim(d.placeOfDelivery()));
|
||||||
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
|
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
|
||||||
v.setRemark(trim(d.remark()));
|
v.setRemark(trim(d.remark()));
|
||||||
v.setFreightTerm(trim(d.freightTerm()));
|
|
||||||
v.setServiceTerm(trim(d.serviceTerm()));
|
|
||||||
v.setPackageUnit(trim(d.packageUnit()));
|
|
||||||
v.setCargoDescription(trim(d.cargoDescription()));
|
|
||||||
v.setMarksAndNos(trim(d.marksAndNos()));
|
|
||||||
v.setDeclaredPackageCount(d.declaredPackageCount());
|
|
||||||
v.setDeclaredGrossWeightKg(d.declaredGrossWeightKg());
|
|
||||||
v.setDeclaredVolumeCbm(d.declaredVolumeCbm());
|
|
||||||
v.setActualPackageCount(d.actualPackageCount());
|
|
||||||
v.setActualGrossWeightKg(d.actualGrossWeightKg());
|
|
||||||
v.setActualVolumeCbm(d.actualVolumeCbm());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
|
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
|
||||||
@@ -431,7 +370,7 @@ public class OceanBookingService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); v.setContactEmail(trim(r.contactEmail())); v.setTaxNo(trim(r.taxNo())); return v; }).toList(); }
|
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
|
||||||
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
|
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
|
||||||
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
|
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-6
@@ -63,7 +63,6 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
|||||||
""")
|
""")
|
||||||
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
|
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
|
||||||
|
|
||||||
/** 到港与日历统一取最新观测的实际记录,后续修正为更早时间时不能被历史最大值覆盖。 */
|
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||||
@@ -71,7 +70,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
|||||||
current.actual_at AS latestActualAt,
|
current.actual_at AS latestActualAt,
|
||||||
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
|
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
|
||||||
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
||||||
MAX(arrival.actual_at) AS arrivedDestinationPortAt,
|
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
|
||||||
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
||||||
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
||||||
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
||||||
@@ -82,10 +81,6 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
|||||||
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
|
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
|
||||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
|
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
|
||||||
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
|
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
|
||||||
LEFT JOIN fl_carrier_schedule_node_fact arrival ON arrival.id=(
|
|
||||||
SELECT a.id FROM fl_carrier_schedule_node_fact a
|
|
||||||
WHERE a.container_booking_id=b.id AND a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL
|
|
||||||
ORDER BY a.observed_at DESC,a.id DESC LIMIT 1)
|
|
||||||
WHERE b.id IN
|
WHERE b.id IN
|
||||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
|
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
|
||||||
|
|||||||
+3
-14
@@ -11,27 +11,16 @@ import java.util.stream.Collectors;
|
|||||||
@Service
|
@Service
|
||||||
public class AppointmentForecastService {
|
public class AppointmentForecastService {
|
||||||
public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest,
|
public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest,
|
||||||
AppointmentForecastMapper.Fact previous,String timeBasis,
|
AppointmentForecastMapper.Fact previous,String timeBasis) { }
|
||||||
java.time.LocalDateTime portArrivalAt) { }
|
|
||||||
private final AppointmentForecastMapper mapper;
|
private final AppointmentForecastMapper mapper;
|
||||||
private final com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources;
|
public AppointmentForecastService(AppointmentForecastMapper mapper){this.mapper=mapper;}
|
||||||
/** 读取船节点及日历权威到港来源,不从调用端接收推算日期。 */
|
|
||||||
public AppointmentForecastService(AppointmentForecastMapper mapper,
|
|
||||||
com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources) {
|
|
||||||
this.mapper=mapper; this.sources=sources;
|
|
||||||
}
|
|
||||||
/** 返回明确来源柜的到港事实,保留未知日期。 */
|
|
||||||
public List<Forecast> batch(List<Long> ids) {
|
public List<Forecast> batch(List<Long> ids) {
|
||||||
if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0)
|
if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0)
|
||||||
|| new HashSet<>(ids).size()!=ids.size()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每批须提供1至200个不重复的柜次");
|
|| new HashSet<>(ids).size()!=ids.size()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每批须提供1至200个不重复的柜次");
|
||||||
var facts=mapper.latest(ids).stream().collect(Collectors.groupingBy(AppointmentForecastMapper.Fact::containerBookingId));
|
var facts=mapper.latest(ids).stream().collect(Collectors.groupingBy(AppointmentForecastMapper.Fact::containerBookingId));
|
||||||
var arrivals=sources.byIds(ids).stream().collect(Collectors.toMap(
|
|
||||||
com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource::containerBookingId,
|
|
||||||
java.util.function.Function.identity()));
|
|
||||||
return ids.stream().map(id->{
|
return ids.stream().map(id->{
|
||||||
var rows=facts.getOrDefault(id,List.of());
|
var rows=facts.getOrDefault(id,List.of());
|
||||||
return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED",
|
return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED");
|
||||||
arrivals.containsKey(id)?arrivals.get(id).arrivalAt():null);
|
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -32,8 +32,6 @@ import java.util.Set;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PortScheduleService {
|
public class PortScheduleService {
|
||||||
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
|
|
||||||
|
|
||||||
private static final Set<String> SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API");
|
private static final Set<String> SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API");
|
||||||
|
|
||||||
@Resource private PortScheduleRepository repository;
|
@Resource private PortScheduleRepository repository;
|
||||||
@@ -74,7 +72,7 @@ public class PortScheduleService {
|
|||||||
validateLinks(request.data());
|
validateLinks(request.data());
|
||||||
PortScheduleEntity value = new PortScheduleEntity();
|
PortScheduleEntity value = new PortScheduleEntity();
|
||||||
value.setId(IdWorker.getId());
|
value.setId(IdWorker.getId());
|
||||||
value.setScheduleNo(documentNumbers.next("PS"));
|
value.setScheduleNo("PS" + value.getId());
|
||||||
copy(value, request.data());
|
copy(value, request.data());
|
||||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
|
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.baiyue.firstleg.repository;
|
|
||||||
|
|
||||||
import com.baiyue.firstleg.mapper.DocumentSequenceMapper;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
|
|
||||||
@Repository
|
|
||||||
public class DocumentSequenceRepository {
|
|
||||||
private final DocumentSequenceMapper mapper;
|
|
||||||
|
|
||||||
/** 只访问所属服务的编号表。 */
|
|
||||||
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
|
|
||||||
|
|
||||||
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
public long next(String key) {
|
|
||||||
mapper.allocate(key);
|
|
||||||
Long value = mapper.allocatedValue();
|
|
||||||
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-12
@@ -4,7 +4,6 @@ import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
|||||||
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
|
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -13,9 +12,6 @@ import java.util.List;
|
|||||||
public class LoadingCompletionCommandRepository {
|
public class LoadingCompletionCommandRepository {
|
||||||
@Resource private LoadingCompletionCommandMapper mapper;
|
@Resource private LoadingCompletionCommandMapper mapper;
|
||||||
|
|
||||||
/** 按事务提交后的命令标识读取唯一完成请求。 */
|
|
||||||
public LoadingCompletionCommandEntity command(long id) { return mapper.selectById(id); }
|
|
||||||
|
|
||||||
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
|
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
|
||||||
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
|
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
|
||||||
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
|
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
|
||||||
@@ -32,13 +28,7 @@ public class LoadingCompletionCommandRepository {
|
|||||||
public String checkpoint(long commandId, long tenantId, long inboundId) {
|
public String checkpoint(long commandId, long tenantId, long inboundId) {
|
||||||
return mapper.checkpoint(commandId, tenantId, inboundId);
|
return mapper.checkpoint(commandId, tenantId, inboundId);
|
||||||
}
|
}
|
||||||
/** 命令行锁覆盖租约检查与检查点写入,旧执行者不能在租约易主后覆盖新结果。 */
|
public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) {
|
||||||
@Transactional
|
mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage);
|
||||||
public boolean checkpoint(long commandId, String worker, long tenantId, long inboundId,
|
|
||||||
int sequence, String stage) {
|
|
||||||
if (mapper.lockCheckpointLease(commandId, worker) == null) return false;
|
|
||||||
int written = mapper.checkpointCompleted(commandId, worker, tenantId, inboundId, sequence, stage);
|
|
||||||
// 同阶段重放可能被驱动计为零行;命令行仍被本事务锁住,重验租约区分幂等与过期拒绝。
|
|
||||||
return written > 0 || mapper.lockCheckpointLease(commandId, worker) != null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -40,8 +40,6 @@ import static com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SailingCalendarService {
|
public class SailingCalendarService {
|
||||||
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
|
|
||||||
|
|
||||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
private static final String OPEN = "OPEN";
|
private static final String OPEN = "OPEN";
|
||||||
@Resource private SailingCalendarRepository repository;
|
@Resource private SailingCalendarRepository repository;
|
||||||
@@ -79,7 +77,7 @@ public class SailingCalendarService {
|
|||||||
if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0");
|
if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0");
|
||||||
SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel);
|
SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel);
|
||||||
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板");
|
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板");
|
||||||
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo(documentNumbers.next("ST"));
|
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId());
|
||||||
copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
||||||
repository.insert(value); return value;
|
repository.insert(value); return value;
|
||||||
@@ -312,7 +310,7 @@ public class SailingCalendarService {
|
|||||||
if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId())
|
if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId())
|
||||||
: throwCopyConflict(request.etdAt(), source.getVoyageNo());
|
: throwCopyConflict(request.etdAt(), source.getVoyageNo());
|
||||||
|
|
||||||
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value);
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value);
|
||||||
try { repository.insert(value); } catch (DuplicateKeyException ex) {
|
try { repository.insert(value); } catch (DuplicateKeyException ex) {
|
||||||
SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(),
|
SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(),
|
||||||
source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo());
|
source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo());
|
||||||
@@ -364,7 +362,7 @@ public class SailingCalendarService {
|
|||||||
if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0");
|
if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0");
|
||||||
validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
|
validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
|
||||||
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期");
|
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期");
|
||||||
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL"));
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId());
|
||||||
copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType()));
|
copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType()));
|
||||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
||||||
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); }
|
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); }
|
||||||
@@ -400,7 +398,7 @@ public class SailingCalendarService {
|
|||||||
validateTemplateSnapshot(template);
|
validateTemplateSnapshot(template);
|
||||||
List<SailingEntity> result = new java.util.ArrayList<>();
|
List<SailingEntity> result = new java.util.ArrayList<>();
|
||||||
for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) {
|
for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) {
|
||||||
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(templateId);
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(templateId);
|
||||||
value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName());
|
value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName());
|
||||||
value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value);
|
value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
|||||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||||
import com.baiyue.firstleg.domain.RealContainerNumber;
|
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||||
@@ -82,19 +82,15 @@ public class ContainerService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首次关联已放舱订舱,仅规范化柜号并保留关联资格与并发校验。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
|
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
|
||||||
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
|
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
|
||||||
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
|
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
|
||||||
String existingContainerNo = trim(container.getContainerNo());
|
String existingContainerNo = trim(container.getContainerNo());
|
||||||
String requestedContainerNo = trim(request.containerNo());
|
String requestedContainerNo = trim(request.containerNo());
|
||||||
// 本关联入口不按 ISO 长度、结构或校验位阻断,仍统一空白与大小写以核对唯一性。
|
String containerNo = requestedContainerNo == null
|
||||||
if (requestedContainerNo != null) {
|
? existingContainerNo
|
||||||
requestedContainerNo = trim(requestedContainerNo.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
|
: Iso6346ContainerNumber.require(requestedContainerNo);
|
||||||
.toUpperCase(java.util.Locale.ROOT));
|
|
||||||
}
|
|
||||||
String containerNo = requestedContainerNo == null ? existingContainerNo : requestedContainerNo;
|
|
||||||
if (existingContainerNo != null && !existingContainerNo.equals(containerNo)) {
|
if (existingContainerNo != null && !existingContainerNo.equals(containerNo)) {
|
||||||
throw conflict("输入的真实集装箱号与当前柜次已录入柜号不一致,请先在预装运输信息中纠正");
|
throw conflict("输入的真实集装箱号与当前柜次已录入柜号不一致,请先在预装运输信息中纠正");
|
||||||
}
|
}
|
||||||
@@ -124,7 +120,6 @@ public class ContainerService {
|
|||||||
return required(id);
|
return required(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提柜后补录规范化真实柜号,保留同订舱唯一及作业冻结门禁。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ContainerBookingEntity assignRealContainerNo(long id, String requestedContainerNo, int expectedVersion,
|
public ContainerBookingEntity assignRealContainerNo(long id, String requestedContainerNo, int expectedVersion,
|
||||||
long operatorId, String operatorName) {
|
long operatorId, String operatorName) {
|
||||||
@@ -139,7 +134,7 @@ public class ContainerService {
|
|||||||
|| repository.hasPickTask(id) || repository.hasEvidence(id) || repository.hasDocuments(id)) {
|
|| repository.hasPickTask(id) || repository.hasEvidence(id) || repository.hasDocuments(id)) {
|
||||||
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
|
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
|
||||||
}
|
}
|
||||||
String containerNo = RealContainerNumber.require(requestedContainerNo);
|
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo);
|
||||||
if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) {
|
if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) {
|
||||||
throw conflict("当前提单下集装箱号已存在");
|
throw conflict("当前提单下集装箱号已存在");
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-23
@@ -1,47 +1,71 @@
|
|||||||
package com.baiyue.firstleg.service;
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
import com.baiyue.common.core.number.DailyDocumentNumber;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
|
||||||
@Component
|
@Component
|
||||||
public class FirstlegDocumentNumberGenerator {
|
public class FirstlegDocumentNumberGenerator {
|
||||||
private static final Set<String> PREFIXES = Set.of("OB", "TA", "LP", "DECL", "BR", "PS", "SL", "ST", "CLG");
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
private final DocumentSequenceRepository sequences;
|
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
||||||
|
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "LP", "DP");
|
||||||
|
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
/** 正式运行使用统一业务时区。 */
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences) {
|
public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) {
|
||||||
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
|
this(jdbc, Clock.system(BUSINESS_ZONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 固定时钟用于跨日及前缀独立性验证。 */
|
FirstlegDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
|
||||||
FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
|
this.jdbc = jdbc;
|
||||||
this.sequences = sequences;
|
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只为本服务已登记的业务类型分配号码。 */
|
@Transactional(propagation = Propagation.MANDATORY)
|
||||||
public String next(String prefix) {
|
public String next(String prefix) {
|
||||||
// 实物标签未纳入本次改号,DP 保留原六位日期及横杠。
|
if (!SUPPORTED_PREFIXES.contains(prefix)) {
|
||||||
if ("DP".equals(prefix)) {
|
throw new IllegalArgumentException("unsupported firstleg document prefix: " + prefix);
|
||||||
java.time.LocalDate date = java.time.LocalDate.now(clock.withZone(DailyDocumentNumber.BUSINESS_ZONE));
|
|
||||||
long value = sequences.next("DP:" + date.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE));
|
|
||||||
DailyDocumentNumber.format("DP", date, value);
|
|
||||||
return "DP-" + date.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd"))
|
|
||||||
+ "-" + String.format(java.util.Locale.ROOT, "%04d", value);
|
|
||||||
}
|
}
|
||||||
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
|
LocalDate sequenceDate = LocalDate.now(clock);
|
||||||
return DailyDocumentNumber.next(prefix, clock, sequences::next);
|
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
||||||
|
long sequence = nextSequence(sequenceKey);
|
||||||
|
if (sequence > MAX_DAILY_SEQUENCE) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽");
|
||||||
|
}
|
||||||
|
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
||||||
|
String.format(Locale.ROOT, "%04d", sequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 柜次号保持现有全局连续规则,不按日期重置,也不限制为四位。 */
|
/** 平台柜次号是长期业务身份,使用不按日期重置的 D 序号。 */
|
||||||
|
@Transactional(propagation = Propagation.MANDATORY)
|
||||||
public String nextCabinetNo() {
|
public String nextCabinetNo() {
|
||||||
return "D" + String.format(java.util.Locale.ROOT, "%04d", sequences.next("CABINET"));
|
return "D" + String.format(Locale.ROOT, "%04d", nextSequence("CABINET"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long nextSequence(String sequenceKey) {
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO fl_document_sequence(sequence_key,current_value) " +
|
||||||
|
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
||||||
|
"current_value=LAST_INSERT_ID(current_value+1)",
|
||||||
|
sequenceKey);
|
||||||
|
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||||
|
if (sequence == null || sequence <= 0) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "业务编号生成失败");
|
||||||
|
}
|
||||||
|
return sequence;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,77 +9,37 @@ import jakarta.annotation.Resource;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.core.task.TaskExecutor;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.event.TransactionalEventListener;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.RejectedExecutionException;
|
|
||||||
|
|
||||||
/** 事务提交后立即处理 PDA 完成命令,并按持久化租约定时恢复。 */
|
/** Lease-based recovery worker for PDA completion commands. */
|
||||||
@Service
|
@Service
|
||||||
public class LoadingCompletionWorker {
|
public class LoadingCompletionWorker {
|
||||||
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
|
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
|
||||||
private static final int MAX_ATTEMPTS = 5;
|
private static final int MAX_ATTEMPTS = 5;
|
||||||
|
private final String workerId = "pda-completion-" + UUID.randomUUID();
|
||||||
|
|
||||||
@Resource private LoadingCompletionCommandRepository commands;
|
@Resource private LoadingCompletionCommandRepository commands;
|
||||||
@Resource private LoadingPickRepository picks;
|
@Resource private LoadingPickRepository picks;
|
||||||
@Resource private LoadingPickService loadingPicks;
|
@Resource private LoadingPickService loadingPicks;
|
||||||
@Resource(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
|
|
||||||
|
|
||||||
/** 唤醒只发生在受理事务提交后,异步执行不延长 HTTP 受理请求。 */
|
|
||||||
@TransactionalEventListener
|
|
||||||
public void accepted(LoadingPickService.CompletionAccepted accepted) {
|
|
||||||
try {
|
|
||||||
completionExecutor.execute(() -> dispatchAccepted(accepted));
|
|
||||||
} catch (RejectedExecutionException exception) {
|
|
||||||
// 执行器繁忙或关闭时保留持久化命令,仍由定时扫描领取,不能影响已提交回执。
|
|
||||||
log.warn("pda_completion_wakeup_deferred commandId={}", accepted.commandId(), exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 唤醒与恢复共用原子领取;同一命令不会被两个执行入口重复结算。 */
|
|
||||||
private void dispatchAccepted(LoadingPickService.CompletionAccepted accepted) {
|
|
||||||
try {
|
|
||||||
LoadingCompletionCommandEntity command = commands.command(accepted.commandId());
|
|
||||||
if (command != null) process(command, "immediate", accepted.acceptedAtNanos());
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
log.warn("pda_completion_wakeup_failed commandId={}", accepted.commandId(), exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 扫描到期或租约过期命令,覆盖唤醒丢失、进程重启及失败退避恢复。 */
|
|
||||||
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
|
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
|
||||||
public void dispatch() {
|
public void dispatch() {
|
||||||
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
|
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
|
||||||
process(candidate, "scheduled", System.nanoTime());
|
if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loadingPicks.processCompletion(candidate, workerId);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
handleFailure(candidate, exception);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保留租约、检查点和失败恢复语义,记录领取及实际处理耗时。 */
|
private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) {
|
||||||
private void process(LoadingCompletionCommandEntity command, String source, long queuedAt) {
|
|
||||||
// 每次领取使用独立所有者,避免旧执行线程在租约过期、同进程重新领取后继续写入。
|
|
||||||
String leaseOwner = "pda-completion-" + UUID.randomUUID();
|
|
||||||
if (!commands.claim(command.getId(), leaseOwner, LocalDateTime.now().plusMinutes(10))) return;
|
|
||||||
long started = System.nanoTime();
|
|
||||||
boolean succeeded = false;
|
|
||||||
log.info("pda_completion_started commandId={} taskId={} source={} wakeupMs={}",
|
|
||||||
command.getId(), command.getPickTaskId(), source, (started - queuedAt) / 1_000_000);
|
|
||||||
try {
|
|
||||||
loadingPicks.processCompletion(command, leaseOwner);
|
|
||||||
succeeded = true;
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
handleFailure(command, leaseOwner, exception);
|
|
||||||
} finally {
|
|
||||||
log.info("pda_completion_finished commandId={} taskId={} source={} durationMs={} succeeded={}",
|
|
||||||
command.getId(), command.getPickTaskId(), source, (System.nanoTime() - started) / 1_000_000,
|
|
||||||
succeeded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 确定性失败关闭任务,其余故障按原退避与最大次数恢复。 */
|
|
||||||
private void handleFailure(LoadingCompletionCommandEntity command, String leaseOwner, RuntimeException exception) {
|
|
||||||
Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException
|
Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException
|
||||||
&& exception.getCause() != null ? exception.getCause() : exception;
|
&& exception.getCause() != null ? exception.getCause() : exception;
|
||||||
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
|
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
|
||||||
@@ -93,7 +53,7 @@ public class LoadingCompletionWorker {
|
|||||||
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
|
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
|
||||||
boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS;
|
boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS;
|
||||||
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
|
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
|
||||||
boolean recorded = commands.fail(command.getId(), leaseOwner, terminal ? "FAILED" : "RETRYABLE", retryAt,
|
boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt,
|
||||||
stage, code, requestId, traceId);
|
stage, code, requestId, traceId);
|
||||||
if (terminal && recorded) {
|
if (terminal && recorded) {
|
||||||
picks.failCompletion(command.getPickTaskId());
|
picks.failCompletion(command.getPickTaskId());
|
||||||
@@ -103,7 +63,6 @@ public class LoadingCompletionWorker {
|
|||||||
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
|
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅将已明确不可重试的本地业务门禁判为终态。 */
|
|
||||||
private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) {
|
private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) {
|
||||||
if (!(cause instanceof BizException business)) {
|
if (!(cause instanceof BizException business)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
|||||||
import com.baiyue.firstleg.repository.LoadingListRepository;
|
import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||||
import com.baiyue.firstleg.domain.RealContainerNumber;
|
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||||
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
|
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
@@ -493,10 +493,10 @@ public class LoadingListService {
|
|||||||
return repository.preloadDetail(requirePlan(planId));
|
return repository.preloadDetail(requirePlan(planId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 未提交柜号时保留原值,首次录入或纠错统一规范化并禁止清空。 */
|
|
||||||
private static String targetContainerNo(AggregateRow before, String requested) {
|
private static String targetContainerNo(AggregateRow before, String requested) {
|
||||||
if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null;
|
if (requested != null) return Iso6346ContainerNumber.require(requested);
|
||||||
return RealContainerNumber.require(requested);
|
return StringUtils.hasText(before.getContainerNo())
|
||||||
|
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String transportHash(long planId, int version, String containerNo,
|
private static String transportHash(long planId, int version, String containerNo,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
|
|||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
|
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
|
||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit;
|
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit;
|
||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
|
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
|
||||||
import com.baiyue.firstleg.domain.RealContainerNumber;
|
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||||
import com.baiyue.firstleg.client.DomesticPdaOperatorClient;
|
import com.baiyue.firstleg.client.DomesticPdaOperatorClient;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||||
@@ -56,7 +56,6 @@ import jakarta.annotation.Resource;
|
|||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -88,8 +87,6 @@ public class LoadingPickService {
|
|||||||
@Resource
|
@Resource
|
||||||
private LoadingCompletionCommandRepository completionCommands;
|
private LoadingCompletionCommandRepository completionCommands;
|
||||||
@Resource
|
@Resource
|
||||||
private ApplicationEventPublisher completionEvents;
|
|
||||||
@Resource
|
|
||||||
private TransactionTemplate completionTransaction;
|
private TransactionTemplate completionTransaction;
|
||||||
@Resource
|
@Resource
|
||||||
private SmartLoadingRepository smartLoadingRepository;
|
private SmartLoadingRepository smartLoadingRepository;
|
||||||
@@ -140,7 +137,6 @@ public class LoadingPickService {
|
|||||||
smartLoadingRepository.items(plan.getId()));
|
smartLoadingRepository.items(plan.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 确认预装前验证作业资格和真实柜号必填。 */
|
|
||||||
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
|
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
|
||||||
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
|
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
|
||||||
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
|
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
|
||||||
@@ -152,7 +148,6 @@ public class LoadingPickService {
|
|||||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||||
throw conflict("确认预装前必须录入真实集装箱号");
|
throw conflict("确认预装前必须录入真实集装箱号");
|
||||||
}
|
}
|
||||||
requireContainerNumberBeforeStarting(booking.getContainerNo());
|
|
||||||
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
|
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
|
||||||
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
|
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
|
||||||
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
||||||
@@ -166,14 +161,12 @@ public class LoadingPickService {
|
|||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅在柜号已满足完成事件要求时创建 PDA 任务,已有任务的重复同步保持原语义。 */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
||||||
List<LoadingPlanItemEntity> planItems) {
|
List<LoadingPlanItemEntity> planItems) {
|
||||||
inboundHoldGuard.requirePlanNotHeld(planId);
|
inboundHoldGuard.requirePlanNotHeld(planId);
|
||||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
requireContainerNumberBeforeStarting(containerNo);
|
|
||||||
task = new LoadingPickTaskEntity();
|
task = new LoadingPickTaskEntity();
|
||||||
task.setLoadingPlanId(planId);
|
task.setLoadingPlanId(planId);
|
||||||
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
|
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
|
||||||
@@ -559,7 +552,6 @@ public class LoadingPickService {
|
|||||||
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
|
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
|
||||||
throw conflict("装柜完成命令状态已变化,请刷新后重试");
|
throw conflict("装柜完成命令状态已变化,请刷新后重试");
|
||||||
}
|
}
|
||||||
completionEvents.publishEvent(new CompletionAccepted(replay.getId(), System.nanoTime()));
|
|
||||||
}
|
}
|
||||||
return view(requiredTask(taskId), null);
|
return view(requiredTask(taskId), null);
|
||||||
}
|
}
|
||||||
@@ -606,37 +598,22 @@ public class LoadingPickService {
|
|||||||
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
|
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
|
||||||
throw conflict("PDA任务版本已变化,请刷新后复核");
|
throw conflict("PDA任务版本已变化,请刷新后复核");
|
||||||
}
|
}
|
||||||
// 提交后立即唤醒仍经持久化租约领取;回滚不得触发结算,定时扫描继续负责故障恢复。
|
|
||||||
completionEvents.publishEvent(new CompletionAccepted(command.getId(), System.nanoTime()));
|
|
||||||
return view(requiredTask(taskId), null);
|
return view(requiredTask(taskId), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
|
|
||||||
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
|
|
||||||
|
|
||||||
/** 确认预装和创建 PDA 任务前要求柜号非空,不以 ISO 格式阻断作业。 */
|
|
||||||
private static void requireContainerNumberBeforeStarting(String containerNo) {
|
|
||||||
try {
|
|
||||||
RealContainerNumber.require(containerNo);
|
|
||||||
} catch (BizException exception) {
|
|
||||||
throw conflict("请先在预装运输信息中录入真实集装箱号后再确认预装或启动PDA");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 完成前复核真实柜号,以生成一致的跨服务装柜完成事件。 */
|
|
||||||
private void requireCompletionContainerNumber(long taskId) {
|
private void requireCompletionContainerNumber(long taskId) {
|
||||||
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
||||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||||
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
RealContainerNumber.require(booking.getContainerNo());
|
Iso6346ContainerNumber.require(booking.getContainerNo());
|
||||||
} catch (BizException exception) {
|
} catch (BizException exception) {
|
||||||
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅在持久化租约领取成功后执行,远程结算不占用受理事务。 */
|
/** Called only by the completion worker, after its durable lease has committed. */
|
||||||
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
||||||
long taskId = command.getPickTaskId();
|
long taskId = command.getPickTaskId();
|
||||||
inboundHoldGuard.requireTaskNotHeld(taskId);
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
||||||
@@ -648,55 +625,55 @@ public class LoadingPickService {
|
|||||||
.toList();
|
.toList();
|
||||||
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
|
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
|
||||||
List<GroupSnapshot> taskGroups = coloadGroups(owners);
|
List<GroupSnapshot> taskGroups = coloadGroups(owners);
|
||||||
runCompletionStage(taskId, "CABINET_EVIDENCE", () ->
|
runCompletionStage("CABINET_EVIDENCE", () ->
|
||||||
cabinetEvidenceService.requireComplete(task.getContainerBookingId()));
|
cabinetEvidenceService.requireComplete(task.getContainerBookingId()));
|
||||||
runCompletionStage(taskId, "COMPLETED_EVENT_READY", () ->
|
runCompletionStage("COMPLETED_EVENT_READY", () ->
|
||||||
loadingListService.requireCompletedEventReady(task.getLoadingPlanId()));
|
loadingListService.requireCompletedEventReady(task.getLoadingPlanId()));
|
||||||
|
|
||||||
// 全柜所有客户先通过只读门禁,再开始资源结算,避免后续客户失败造成前序客户局部完成。
|
// Phase 1 is read-only: cabinet evidence is checked once, then every owner must pass its business gates
|
||||||
|
// before any warehouse resource or inbound status can be changed. This prevents a later customer gate from
|
||||||
|
// leaving earlier customers partially completed.
|
||||||
for (int index = 0; index < owners.size(); index++) {
|
for (int index = 0; index < owners.size(); index++) {
|
||||||
requireCompletionLease(command.getId(), worker);
|
requireCompletionLease(command.getId(), worker);
|
||||||
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
||||||
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
||||||
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
||||||
runCompletionStage(taskId, "OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
|
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
|
||||||
owner.tenantId(), owner.inboundId()));
|
owner.tenantId(), owner.inboundId()));
|
||||||
if (!checkpointAtLeast(checkpoint, "READY")) {
|
if (!checkpointAtLeast(checkpoint, "READY")) {
|
||||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(),
|
requireCompletionLease(command.getId(), worker);
|
||||||
|
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(),
|
||||||
index + 1, "READY");
|
index + 1, "READY");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全柜门禁通过后,按客户检查点和稳定幂等键逐步结算,已完成的检查点可安全续做。
|
// Phase 2 is recoverable by per-owner checkpoints and stable idempotency keys. It starts only after the
|
||||||
|
// complete cabinet passed phase 1, while prior deployments' completed checkpoints remain safely resumable.
|
||||||
for (int index = 0; index < owners.size(); index++) {
|
for (int index = 0; index < owners.size(); index++) {
|
||||||
requireCompletionLease(command.getId(), worker);
|
requireCompletionLease(command.getId(), worker);
|
||||||
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
||||||
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
||||||
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
||||||
runCompletionStage(taskId, "WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
|
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
|
||||||
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
|
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
|
||||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
|
requireCompletionLease(command.getId(), worker);
|
||||||
|
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
|
||||||
}
|
}
|
||||||
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
|
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
|
||||||
runCompletionStage(taskId, "INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
|
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
|
||||||
owner.tenantId(), owner.inboundId(),
|
owner.tenantId(), owner.inboundId(),
|
||||||
command.getOperatorId(), command.getOperatorName(),
|
command.getOperatorId(), command.getOperatorName(),
|
||||||
"PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
|
"PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
|
||||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1,
|
requireCompletionLease(command.getId(), worker);
|
||||||
|
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
|
||||||
"INBOUND_COMPLETED");
|
"INBOUND_COMPLETED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
long transactionStarted = System.nanoTime();
|
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
|
||||||
try {
|
|
||||||
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
|
|
||||||
} finally {
|
|
||||||
log.info("pda_completion_transaction taskId={} durationMs={}", taskId,
|
|
||||||
(System.nanoTime() - transactionStarted) / 1_000_000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 远程结算完成后,仅在短事务内收敛 Firstleg 本地完成事实。 */
|
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
|
||||||
private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
|
private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
|
||||||
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
|
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
|
||||||
long taskId = task.getId();
|
long taskId = task.getId();
|
||||||
@@ -711,11 +688,11 @@ public class LoadingPickService {
|
|||||||
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
|
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
|
||||||
command.getOperatorId(), command.getOperatorName());
|
command.getOperatorId(), command.getOperatorName());
|
||||||
}
|
}
|
||||||
runCompletionStage(taskId, "PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
||||||
command.getOperatorId(), command.getOperatorName()));
|
command.getOperatorId(), command.getOperatorName()));
|
||||||
runCompletionStage(taskId, "COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
||||||
for (GroupSnapshot group : taskGroups) {
|
for (GroupSnapshot group : taskGroups) {
|
||||||
runCompletionStage(taskId, "COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
|
runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
|
||||||
task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|'
|
task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|'
|
||||||
+ group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName()));
|
+ group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName()));
|
||||||
}
|
}
|
||||||
@@ -773,14 +750,6 @@ public class LoadingPickService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 检查点失去当前有效租约时立即停止,不再推进后续客户或柜次。 */
|
|
||||||
private void recordCompletionCheckpoint(long commandId, String worker, long tenantId, long inboundId,
|
|
||||||
int sequence, String stage) {
|
|
||||||
if (!completionCommands.checkpoint(commandId, worker, tenantId, inboundId, sequence, stage)) {
|
|
||||||
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
|
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
|
||||||
String requestHash, long operatorId) {
|
String requestHash, long operatorId) {
|
||||||
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|
||||||
@@ -795,18 +764,11 @@ public class LoadingPickService {
|
|||||||
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
|
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 记录实际阶段耗时,区分业务处理与调度、前端刷新等待。 */
|
private static void runCompletionStage(String stage, Runnable action) {
|
||||||
private static void runCompletionStage(long taskId, String stage, Runnable action) {
|
|
||||||
long started = System.nanoTime();
|
|
||||||
boolean succeeded = false;
|
|
||||||
try {
|
try {
|
||||||
action.run();
|
action.run();
|
||||||
succeeded = true;
|
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
throw new CompletionProcessingException(stage, exception);
|
throw new CompletionProcessingException(stage, exception);
|
||||||
} finally {
|
|
||||||
log.info("pda_completion_stage taskId={} stage={} durationMs={} succeeded={}",
|
|
||||||
taskId, stage, (System.nanoTime() - started) / 1_000_000, succeeded);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,13 +91,12 @@ public class SmartLoadingService {
|
|||||||
}
|
}
|
||||||
return repository.candidates(warehouseId);
|
return repository.candidates(warehouseId);
|
||||||
}
|
}
|
||||||
/** 按柜次渠道查询候选,关联订舱客户快照不限制其他客户的货物。 */
|
|
||||||
public List<Candidate> candidates(long bookingId, long warehouseId) {
|
public List<Candidate> candidates(long bookingId, long warehouseId) {
|
||||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||||
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
|
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
|
||||||
if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
|
if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
|
||||||
List<Candidate> values = candidates(warehouseId).stream()
|
List<Candidate> values = candidates(warehouseId).stream()
|
||||||
.filter(candidate -> sameChannel(booking, candidate)).toList();
|
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
|
||||||
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
|
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +108,10 @@ public class SmartLoadingService {
|
|||||||
return uniqueCandidateUnits(grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList());
|
return uniqueCandidateUnits(grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 推荐保留渠道和显式筛选范围,允许同柜包含多个客户。 */
|
|
||||||
private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking,
|
private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking,
|
||||||
Long customerId, String inboundCode) {
|
Long customerId, String inboundCode) {
|
||||||
List<Candidate> values = repository.candidates(null).stream()
|
List<Candidate> values = repository.candidates(null).stream()
|
||||||
.filter(candidate -> sameChannel(booking, candidate))
|
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate))
|
||||||
.filter(candidate -> customerId == null || Objects.equals(customerId, candidate.accountId()))
|
.filter(candidate -> customerId == null || Objects.equals(customerId, candidate.accountId()))
|
||||||
.filter(candidate -> !StringUtils.hasText(inboundCode)
|
.filter(candidate -> !StringUtils.hasText(inboundCode)
|
||||||
|| inboundCode.trim().equals(candidate.inboundCode()))
|
|| inboundCode.trim().equals(candidate.inboundCode()))
|
||||||
@@ -515,7 +513,6 @@ public class SmartLoadingService {
|
|||||||
return view(assigned, requireBooking(assigned.getContainerBookingId()));
|
return view(assigned, requireBooking(assigned.getContainerBookingId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 原子新增明细或整托组成,使用货物自身客户归属而非订舱客户快照。 */
|
|
||||||
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
|
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
|
||||||
boolean wholeGroupAssignment, Long palletId, String palletNo,
|
boolean wholeGroupAssignment, Long palletId, String palletNo,
|
||||||
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate,
|
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate,
|
||||||
@@ -538,6 +535,7 @@ public class SmartLoadingService {
|
|||||||
}
|
}
|
||||||
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
|
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
|
||||||
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
|
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
|
||||||
|
requireSameCustomerScope(booking, candidate);
|
||||||
var task = loadingPickService.mutationSnapshot(planId);
|
var task = loadingPickService.mutationSnapshot(planId);
|
||||||
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
|
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
|
||||||
splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(),
|
splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(),
|
||||||
@@ -606,7 +604,10 @@ public class SmartLoadingService {
|
|||||||
return assignInbound(planId, request, operatorId, operatorName, false, null, true);
|
return assignInbound(planId, request, operatorId, operatorName, false, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待审核订单可跨客户占位;权威订单归属和后续审核测量门禁仍必须满足。 */
|
/**
|
||||||
|
* Reserves declared cartons in a cabinet before order review. Review status is not an allocation
|
||||||
|
* gate; confirmation/PDA remains blocked until the resulting inbound reaches WAIT_SPLIT.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN",
|
@BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN",
|
||||||
resourceId = "#planId", description = "待审核订单加入装柜清单")
|
resourceId = "#planId", description = "待审核订单加入装柜清单")
|
||||||
@@ -651,6 +652,7 @@ public class SmartLoadingService {
|
|||||||
for (InboundAllocation allocation : allocations) {
|
for (InboundAllocation allocation : allocations) {
|
||||||
if (allocation.packageCount() == 0) continue;
|
if (allocation.packageCount() == 0) continue;
|
||||||
Candidate candidate = allocation.candidate();
|
Candidate candidate = allocation.candidate();
|
||||||
|
requireSameCustomerScope(booking, candidate);
|
||||||
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
||||||
OrderCargoClient.CargoBinding cargo = declared.stream()
|
OrderCargoClient.CargoBinding cargo = declared.stream()
|
||||||
.map(DeclaredCargo::cargo)
|
.map(DeclaredCargo::cargo)
|
||||||
@@ -720,7 +722,7 @@ public class SmartLoadingService {
|
|||||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
|
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
|
||||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||||
List<Candidate> details = frozenDetails == null
|
List<Candidate> details = frozenDetails == null
|
||||||
? manualCandidates().stream()
|
? manualCandidates(booking).stream()
|
||||||
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
|
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
|
||||||
: List.copyOf(frozenDetails);
|
: List.copyOf(frozenDetails);
|
||||||
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
|
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
|
||||||
@@ -754,7 +756,6 @@ public class SmartLoadingService {
|
|||||||
return view(assigned, booking);
|
return view(assigned, booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按入仓单自己的客户和申报事实建立占位,不以订舱客户限制拼柜。 */
|
|
||||||
private Plan assignDeclaredInbound(long planId, AssignInboundRequest request,
|
private Plan assignDeclaredInbound(long planId, AssignInboundRequest request,
|
||||||
InboundOrderEntity projection, String operation,
|
InboundOrderEntity projection, String operation,
|
||||||
String requestHash, long operatorId, String operatorName) {
|
String requestHash, long operatorId, String operatorName) {
|
||||||
@@ -785,6 +786,7 @@ public class SmartLoadingService {
|
|||||||
for (InboundAllocation allocation : allocations) {
|
for (InboundAllocation allocation : allocations) {
|
||||||
if (allocation.packageCount() == 0) continue;
|
if (allocation.packageCount() == 0) continue;
|
||||||
Candidate candidate = allocation.candidate();
|
Candidate candidate = allocation.candidate();
|
||||||
|
requireSameCustomerScope(booking, candidate);
|
||||||
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
||||||
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
|
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
|
||||||
.filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId()))
|
.filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId()))
|
||||||
@@ -1005,7 +1007,6 @@ public class SmartLoadingService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
|
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
|
||||||
resourceId = "#itemId", description = "调整智能装柜明细")
|
resourceId = "#itemId", description = "调整智能装柜明细")
|
||||||
/** 调整本计划明细箱数,保留原客户归属、可用量、拣货和容量校验。 */
|
|
||||||
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
|
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
|
||||||
String operation = operation(request.operationId());
|
String operation = operation(request.operationId());
|
||||||
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|'
|
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|'
|
||||||
@@ -1052,6 +1053,7 @@ public class SmartLoadingService {
|
|||||||
if (request.packageCount() > maxPackageCount) {
|
if (request.packageCount() > maxPackageCount) {
|
||||||
throw validation("调整箱数超过可用箱数");
|
throw validation("调整箱数超过可用箱数");
|
||||||
}
|
}
|
||||||
|
requireSameCustomerScope(booking, candidate);
|
||||||
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
||||||
others.removeIf(x -> Objects.equals(x.getId(), itemId));
|
others.removeIf(x -> Objects.equals(x.getId(), itemId));
|
||||||
checkRouteAndCapacity(booking, others, candidate, request.packageCount(), true);
|
checkRouteAndCapacity(booking, others, candidate, request.packageCount(), true);
|
||||||
@@ -1358,7 +1360,7 @@ public class SmartLoadingService {
|
|||||||
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
|
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
|
||||||
throw conflict("同柜不可拆组已锁定或已消费");
|
throw conflict("同柜不可拆组已锁定或已消费");
|
||||||
}
|
}
|
||||||
List<Candidate> available = manualCandidates();
|
List<Candidate> available = manualCandidates(booking);
|
||||||
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
|
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
|
||||||
for (Member member : group.members()) {
|
for (Member member : group.members()) {
|
||||||
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
|
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
|
||||||
@@ -1380,15 +1382,14 @@ public class SmartLoadingService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 整组推荐可跨客户,但仍按柜次渠道选择候选。 */
|
|
||||||
private List<Candidate> availableCandidates(ContainerBookingEntity booking) {
|
private List<Candidate> availableCandidates(ContainerBookingEntity booking) {
|
||||||
return repository.candidates(null).stream()
|
return repository.candidates(null).stream()
|
||||||
.filter(candidate -> sameChannel(booking, candidate)).toList();
|
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 平台人工分配可拼装多个客户,明细始终保留各自的真实客户归属。 */
|
private List<Candidate> manualCandidates(ContainerBookingEntity booking) {
|
||||||
private List<Candidate> manualCandidates() {
|
return repository.candidates(null).stream()
|
||||||
return repository.candidates(null);
|
.filter(candidate -> sameCustomerScope(booking, candidate)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
|
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
|
||||||
@@ -1806,6 +1807,12 @@ public class SmartLoadingService {
|
|||||||
return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
|
return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
|
||||||
&& booking.getChannelGroupCode().equals(candidateGroup.code());
|
&& booking.getChannelGroupCode().equals(candidateGroup.code());
|
||||||
}
|
}
|
||||||
|
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
|
||||||
|
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
|
||||||
|
}
|
||||||
|
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
|
||||||
|
if (!sameCustomerScope(booking, candidate)) throw forbidden();
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot.
|
* A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot.
|
||||||
* The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and
|
* The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and
|
||||||
@@ -1904,5 +1911,5 @@ public class SmartLoadingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
|
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
|
||||||
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);}
|
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.dto.TruckAppointmentDtos.*;
|
|||||||
import com.baiyue.firstleg.entity.*;
|
import com.baiyue.firstleg.entity.*;
|
||||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
|
||||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||||
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
|
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -35,6 +36,7 @@ public class TruckAppointmentService {
|
|||||||
@Resource TruckAppointmentRepository repo;
|
@Resource TruckAppointmentRepository repo;
|
||||||
@Resource TruckAppointmentCompletionService completion;
|
@Resource TruckAppointmentCompletionService completion;
|
||||||
@Resource OceanBookingRepository oceanBookings;
|
@Resource OceanBookingRepository oceanBookings;
|
||||||
|
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
||||||
@Resource TruckAppointmentFileClient files;
|
@Resource TruckAppointmentFileClient files;
|
||||||
@Resource WarehouseSupplierSnapshotService suppliers;
|
@Resource WarehouseSupplierSnapshotService suppliers;
|
||||||
@Resource FirstlegDocumentNumberGenerator documentNumbers;
|
@Resource FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
@@ -62,13 +64,13 @@ public class TruckAppointmentService {
|
|||||||
return completeOperation(id,action,idem,enrich(required(id)));
|
return completeOperation(id,action,idem,enrich(required(id)));
|
||||||
}
|
}
|
||||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); TruckAppointmentEntity active=repo.firstActiveForUpdate(e.getOceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);completion.completeForBooking(e.getOceanBookingId());return completeOperation(id,action,idem,enrich(required(id)));}
|
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); TruckAppointmentEntity active=repo.firstActiveForUpdate(e.getOceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);completion.completeForBooking(e.getOceanBookingId());return completeOperation(id,action,idem,enrich(required(id)));}
|
||||||
/** 从已放舱订舱校验仓库和提单号,附件不作为预约条件。 */
|
|
||||||
private AssociationContext validateAssociations(SaveRequest r){
|
private AssociationContext validateAssociations(SaveRequest r){
|
||||||
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
||||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||||
if(!"RELEASED".equals(booking.getStatus()))throw conflict("拖车预约只能选择已放舱订舱");
|
if(!"RELEASED".equals(booking.getStatus()))throw conflict("拖车预约只能选择已放舱订舱");
|
||||||
Long warehouseId=mergeWarehouse(r.warehouseId(),booking.getWarehouseId(),"订舱");
|
Long warehouseId=mergeWarehouse(r.warehouseId(),booking.getWarehouseId(),"订舱");
|
||||||
String billOfLadingNo=releasedBookingBill(booking,trim(r.billOfLadingNo()));
|
String billOfLadingNo=releasedBookingBill(booking,trim(r.billOfLadingNo()));
|
||||||
|
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||||
return new AssociationContext(booking,warehouseId,billOfLadingNo);
|
return new AssociationContext(booking,warehouseId,billOfLadingNo);
|
||||||
}
|
}
|
||||||
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");if(r.payableAmount()!=null&&(r.payableAmount().signum()<0||r.payableAmount().scale()>2))throw bad("拖车费必须为非负数且最多两位小数");}
|
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");if(r.payableAmount()!=null&&(r.payableAmount().signum()<0||r.payableAmount().scale()>2))throw bad("拖车费必须为非负数且最多两位小数");}
|
||||||
@@ -83,13 +85,13 @@ public class TruckAppointmentService {
|
|||||||
private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");}
|
private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");}
|
||||||
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
|
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
|
||||||
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
|
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
|
||||||
/** 派车时复核订舱放舱状态及提单号,不要求订舱附件。 */
|
|
||||||
private String requireDispatchReady(TruckAppointmentEntity appointment){
|
private String requireDispatchReady(TruckAppointmentEntity appointment){
|
||||||
if(appointment.getOceanBookingId()==null)throw conflict("拖车预约缺少订舱关联");
|
if(appointment.getOceanBookingId()==null)throw conflict("拖车预约缺少订舱关联");
|
||||||
OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());
|
OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());
|
||||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||||
if(!"RELEASED".equals(booking.getStatus()))throw conflict("关联订舱尚未放舱");
|
if(!"RELEASED".equals(booking.getStatus()))throw conflict("关联订舱尚未放舱");
|
||||||
String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo()));
|
String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo()));
|
||||||
|
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||||
return bill;
|
return bill;
|
||||||
}
|
}
|
||||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||||
|
|||||||
-19
@@ -9,25 +9,6 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface ContainerCalendarSourceMapper {
|
public interface ContainerCalendarSourceMapper {
|
||||||
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
|
|
||||||
@Select("""
|
|
||||||
<script>
|
|
||||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
|
||||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
|
||||||
c.destination_warehouse_code destinationWarehouseCode,
|
|
||||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
|
||||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
|
||||||
FROM fl_container_booking c
|
|
||||||
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
|
|
||||||
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
|
|
||||||
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
|
|
||||||
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
|
|
||||||
WHERE c.record_status='ACTIVE' AND c.id IN
|
|
||||||
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
|
||||||
</script>
|
|
||||||
""")
|
|
||||||
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
|
||||||
|
|
||||||
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||||
|
|||||||
-2
@@ -11,8 +11,6 @@ public class ContainerCalendarSourceRepository {
|
|||||||
private final ContainerCalendarSourceMapper mapper;
|
private final ContainerCalendarSourceMapper mapper;
|
||||||
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
|
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
|
||||||
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
|
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
|
||||||
/** 内部批量读取明确柜次,日期口径与平台日历一致。 */
|
|
||||||
public List<ContainerCalendarSource> byIds(List<Long> ids) { return mapper.byIds(ids); }
|
|
||||||
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
|
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
|
||||||
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
|
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
|
||||||
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
|
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ public interface WorkflowFactMapper {
|
|||||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
|
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
|
||||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
|
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
|
||||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
|
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
|
||||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready,"
|
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready "
|
||||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_customs_container c WHERE c.loading_plan_id=p.id AND c.status='COMPLETED')) customs_completed "
|
|
||||||
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
|
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
|
||||||
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||||
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
|
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ public final class WorkflowFacts {
|
|||||||
/** 仅承载事实查询契约,不允许实例化。 */
|
/** 仅承载事实查询契约,不允许实例化。 */
|
||||||
private WorkflowFacts() {}
|
private WorkflowFacts() {}
|
||||||
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,
|
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,
|
||||||
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,boolean customsCompleted) {}
|
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {}
|
||||||
public record Source(long tenantId,long inboundId) {}
|
public record Source(long tenantId,long inboundId) {}
|
||||||
public record Request(List<Source> sources) {}
|
public record Request(List<Source> sources) {}
|
||||||
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
|
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class WorkflowTabService {
|
|||||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
||||||
// 确认页签复用已推送费用版本。
|
// 确认页签复用已推送费用版本。
|
||||||
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
|
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
|
||||||
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
|
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
|
||||||
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
|
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
|
||||||
}
|
}
|
||||||
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||||
@@ -32,24 +32,19 @@ public class WorkflowTabService {
|
|||||||
if(!sources.isEmpty()) {
|
if(!sources.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
||||||
if(response==null || !response.successful() || response.data()==null) throw unavailable(
|
if(response==null || !response.successful() || response.data()==null) throw unavailable();
|
||||||
"尾程事实响应无效 code="+(response==null?null:response.code())
|
|
||||||
+" requestId="+(response==null?null:response.requestId())+" traceId="+(response==null?null:response.traceId()));
|
|
||||||
var allowed=new HashSet<>(sources);
|
var allowed=new HashSet<>(sources);
|
||||||
for(var fact:response.data()) {
|
for(var fact:response.data()) {
|
||||||
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
|
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable();
|
||||||
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
|
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
|
||||||
}
|
}
|
||||||
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
|
if(overseas.size()!=sources.size()) throw unavailable();
|
||||||
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
|
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
|
||||||
} catch(BizException e) { throw e; } catch(Exception e) {
|
|
||||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
||||||
for(var fact:facts) {
|
for(var fact:facts) {
|
||||||
var inbound=expected.get(fact.inboundId());
|
var inbound=expected.get(fact.inboundId());
|
||||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId());
|
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
|
||||||
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
|
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
|
||||||
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
|
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
|
||||||
}
|
}
|
||||||
@@ -61,18 +56,16 @@ public class WorkflowTabService {
|
|||||||
Set<String> keys=new LinkedHashSet<>();
|
Set<String> keys=new LinkedHashSet<>();
|
||||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||||
boolean signed="DELIVERED".equals(process);
|
boolean signed="DELIVERED".equals(process);
|
||||||
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
|
// 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。
|
||||||
boolean clearanceStage=f.departed();
|
if(!f.departed() && !signed) {
|
||||||
if(!clearanceStage && !signed) {
|
|
||||||
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
|
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
|
||||||
if(f.exportCustomsReady() || f.customsCompleted()) keys.add("EXPORT_CUSTOMS");
|
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
|
||||||
}
|
}
|
||||||
if(signed) keys.add("SIGNED");
|
if(signed) keys.add("SIGNED");
|
||||||
if(clearanceStage && !signed) {
|
if(f.departed() && !signed) {
|
||||||
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
|
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
|
||||||
// 清关资料保存成功后才进入海外待办;出库事实优先于拆柜和排车待办。
|
if(o!=null) {
|
||||||
if(f.clearanceUploaded() && o!=null) {
|
if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD");
|
||||||
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
|
|
||||||
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
|
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
|
||||||
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
|
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
|
||||||
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
||||||
@@ -82,9 +75,5 @@ public class WorkflowTabService {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
/** 下游缺失或归属异常不能生成部分数量。 */
|
/** 下游缺失或归属异常不能生成部分数量。 */
|
||||||
private static BizException unavailable(String reason) {
|
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
|
||||||
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
|
|
||||||
return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试")
|
|
||||||
.initCause(new IllegalStateException(reason));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
|
|||||||
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
|
||||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
|
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
|
||||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
|
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
|
||||||
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
||||||
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
|
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
|
||||||
channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照',
|
channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||||
@@ -27,17 +27,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
|||||||
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
|
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
|
||||||
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
|
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
|
||||||
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||||
freight_term VARCHAR(64) NULL COMMENT '运费条款',
|
|
||||||
service_term VARCHAR(64) NULL COMMENT '运输条款',
|
|
||||||
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
|
|
||||||
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
|
|
||||||
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
|
|
||||||
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
|
|
||||||
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
|
|
||||||
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
|
|
||||||
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
|
|
||||||
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
|
|
||||||
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
|
|
||||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
|
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
|
||||||
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
|
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
||||||
@@ -55,14 +44,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
|||||||
('DRAFT','RELEASED')),
|
('DRAFT','RELEASED')),
|
||||||
CONSTRAINT chk_ocean_booking_carrier_code CHECK
|
CONSTRAINT chk_ocean_booking_carrier_code CHECK
|
||||||
(carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
|
(carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
|
||||||
CONSTRAINT chk_ocean_booking_cargo CHECK (
|
|
||||||
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
|
|
||||||
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
|
|
||||||
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
|
|
||||||
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
|
|
||||||
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
|
|
||||||
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
|
|
||||||
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
|
|
||||||
CONSTRAINT chk_ocean_booking_schedule CHECK
|
CONSTRAINT chk_ocean_booking_schedule CHECK
|
||||||
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
|
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
|
||||||
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||||
@@ -129,8 +110,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
|
|||||||
address VARCHAR(1000) NULL COMMENT '关系方地址快照',
|
address VARCHAR(1000) NULL COMMENT '关系方地址快照',
|
||||||
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
|
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
|
||||||
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
|
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
|
||||||
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
|
|
||||||
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
||||||
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
|
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
|
||||||
@@ -232,46 +211,8 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
|
|||||||
DELIMITER $$
|
DELIMITER $$
|
||||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||||
BEGIN
|
BEGIN
|
||||||
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
|
IF OLD.status = 'RELEASED' THEN
|
||||||
IF OLD.status = 'RELEASED' AND NOT (
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
|
||||||
(NEW.id <=> OLD.id)
|
|
||||||
AND (NEW.booking_no <=> OLD.booking_no)
|
|
||||||
AND (NEW.status <=> OLD.status)
|
|
||||||
AND (NEW.customer_id <=> OLD.customer_id)
|
|
||||||
AND (NEW.customer_name <=> OLD.customer_name)
|
|
||||||
AND (NEW.channel_code <=> OLD.channel_code)
|
|
||||||
AND (NEW.channel_name <=> OLD.channel_name)
|
|
||||||
AND (NEW.warehouse_id <=> OLD.warehouse_id)
|
|
||||||
AND (NEW.warehouse_name <=> OLD.warehouse_name)
|
|
||||||
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
|
|
||||||
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
|
|
||||||
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
|
|
||||||
AND (NEW.carrier_code <=> OLD.carrier_code)
|
|
||||||
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
|
|
||||||
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
|
|
||||||
AND (NEW.vessel_name <=> OLD.vessel_name)
|
|
||||||
AND (NEW.voyage_no <=> OLD.voyage_no)
|
|
||||||
AND (NEW.departure_port_code <=> OLD.departure_port_code)
|
|
||||||
AND (NEW.departure_port_name <=> OLD.departure_port_name)
|
|
||||||
AND (NEW.destination_port_code <=> OLD.destination_port_code)
|
|
||||||
AND (NEW.destination_port_name <=> OLD.destination_port_name)
|
|
||||||
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
|
|
||||||
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
|
|
||||||
AND (NEW.remark <=> OLD.remark)
|
|
||||||
AND (NEW.freight_term <=> OLD.freight_term)
|
|
||||||
AND (NEW.service_term <=> OLD.service_term)
|
|
||||||
AND (NEW.package_unit <=> OLD.package_unit)
|
|
||||||
AND (NEW.cargo_description <=> OLD.cargo_description)
|
|
||||||
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
|
|
||||||
AND (NEW.declared_package_count <=> OLD.declared_package_count)
|
|
||||||
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
|
|
||||||
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
|
|
||||||
AND (NEW.created_by <=> OLD.created_by)
|
|
||||||
AND (NEW.created_by_name <=> OLD.created_by_name)
|
|
||||||
AND (NEW.created_at <=> OLD.created_at)
|
|
||||||
AND NEW.version = OLD.version + 1
|
|
||||||
) THEN
|
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
|
|
||||||
END IF;
|
END IF;
|
||||||
IF OLD.status <> NEW.status AND NOT (
|
IF OLD.status <> NEW.status AND NOT (
|
||||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ CALL phase3_add_allocation_picked_count();
|
|||||||
DROP PROCEDURE phase3_add_allocation_picked_count;
|
DROP PROCEDURE phase3_add_allocation_picked_count;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||||
sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
|
sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
|
||||||
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
|
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
|
||||||
) ENGINE=InnoDB COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
|
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||||
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;订舱关联不限制ISO格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
|
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
|
||||||
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
|
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
|
||||||
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
|
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
|
||||||
@@ -100,7 +100,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
|||||||
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
|
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
|
||||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
|
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
|
||||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')
|
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
|
||||||
|
CONSTRAINT chk_container_booking_iso6346 CHECK
|
||||||
|
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
|
||||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
|
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
|
||||||
@@ -226,6 +228,11 @@ BEGIN
|
|||||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
|
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
|
||||||
CHECK (record_status IN ('ACTIVE','MERGED'));
|
CHECK (record_status IN ('ACTIVE','MERGED'));
|
||||||
END IF;
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
||||||
|
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_iso6346') THEN
|
||||||
|
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
|
||||||
|
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
|
||||||
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
||||||
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
|
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
|
||||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
|
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
containerBookingId: {type: string}
|
containerBookingId: {type: string}
|
||||||
timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]}
|
timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]}
|
||||||
portArrivalAt: {type: string, format: date-time, nullable: true, description: 与预约日历相同的权威到港当地时间,最新有效实际到港优先,其次柜资料实际到港及预计到港;未知保持空}
|
|
||||||
latest: {$ref: '#/components/schemas/Fact'}
|
latest: {$ref: '#/components/schemas/Fact'}
|
||||||
previous: {$ref: '#/components/schemas/Fact'}
|
previous: {$ref: '#/components/schemas/Fact'}
|
||||||
'401': {description: 内部身份缺失、错误或服务端未配置}
|
'401': {description: 内部身份缺失、错误或服务端未配置}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ paths:
|
|||||||
summary: 按入仓码查询同柜组业务摘要
|
summary: 按入仓码查询同柜组业务摘要
|
||||||
description: >-
|
description: >-
|
||||||
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
|
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
|
||||||
返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
||||||
页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。
|
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。
|
||||||
parameters:
|
parameters:
|
||||||
- name: inboundCode
|
- name: inboundCode
|
||||||
in: path
|
in: path
|
||||||
@@ -147,9 +147,8 @@ components:
|
|||||||
ColoadGroupBusinessSummary:
|
ColoadGroupBusinessSummary:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [groupNo, status, memberCount, members]
|
required: [status, memberCount, members]
|
||||||
properties:
|
properties:
|
||||||
groupNo: {type: string, pattern: "^CLG[0-9]{12}$", description: 真实同柜业务组号;与创建及重放结果一致}
|
|
||||||
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
|
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
|
||||||
memberCount: {type: integer, minimum: 1}
|
memberCount: {type: integer, minimum: 1}
|
||||||
members:
|
members:
|
||||||
|
|||||||
@@ -118,9 +118,9 @@ paths:
|
|||||||
x-permission: platform:firstleg:customs:list
|
x-permission: platform:firstleg:customs:list
|
||||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
|
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
|
||||||
get:
|
get:
|
||||||
summary: 平台下载预录单
|
summary: 平台下载报关行回执
|
||||||
x-permission: platform:firstleg:customs:list
|
x-permission: platform:firstleg:customs:list
|
||||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||||
@@ -257,7 +257,7 @@ components:
|
|||||||
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
||||||
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
||||||
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||||
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||||
ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}}
|
ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}}
|
||||||
ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}}
|
ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}}
|
||||||
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ paths:
|
|||||||
customsOrderIds: {type: array, items: {type: integer, format: int64}}
|
customsOrderIds: {type: array, items: {type: integer, format: int64}}
|
||||||
progress:
|
progress:
|
||||||
type: array
|
type: array
|
||||||
description: 实际装载片段投影;报关要求COMPLETED,目的港清关以本柜DESTINATION_CLEARANCE资料成功绑定file_id为准,不等待PICKUP_READY船节点。
|
description: 实际装载片段投影;报关要求COMPLETED,目的港清关要求PICKUP_READY实际时间,缺失节点不推测。
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
required: [orderId, stage, partial]
|
required: [orderId, stage, partial]
|
||||||
@@ -607,7 +607,7 @@ paths:
|
|||||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||||
post:
|
post:
|
||||||
summary: 将内部模拟柜关联到已发布订舱需求
|
summary: 将内部模拟柜关联到已发布订舱需求
|
||||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仅移除空白并转大写,不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。运输信息、提柜补录、确认预装、创建PDA任务、装柜完成及跨服务事件统一不校验ISO格式;确认预装及创建PDA任务前仍必须补齐真实柜号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||||
@@ -700,8 +700,7 @@ paths:
|
|||||||
需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中
|
需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中
|
||||||
对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。
|
对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。
|
||||||
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道,
|
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道,
|
||||||
仍由服务端重新校验国内仓、货物原客户归属、占用、同柜组、箱数和容量资格。
|
仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。
|
||||||
平台柜次支持跨客户拼柜,关联订舱的客户快照不限制柜内成员客户。
|
|
||||||
parameters:
|
parameters:
|
||||||
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
responses:
|
responses:
|
||||||
@@ -836,7 +835,6 @@ paths:
|
|||||||
description: >-
|
description: >-
|
||||||
需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围;
|
需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围;
|
||||||
传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。
|
传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。
|
||||||
关联订舱的客户快照不裁剪候选,未指定客户筛选时可推荐多个客户的货物。
|
|
||||||
一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。
|
一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。
|
||||||
parameters:
|
parameters:
|
||||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
@@ -850,8 +848,7 @@ paths:
|
|||||||
summary: 向预装计划新增入库单明细
|
summary: 向预装计划新增入库单明细
|
||||||
description: >-
|
description: >-
|
||||||
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
|
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
|
||||||
校验货物原客户归属、可用箱数、容积和限重后原子新增/累加明细;平台允许跨客户拼柜,
|
校验客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单国内仓及渠道与柜次不同,
|
||||||
不以关联订舱客户快照限定成员客户。人工加入允许订单国内仓及渠道与柜次不同,
|
|
||||||
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
|
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
|
||||||
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
|
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
|
||||||
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
|
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
|
||||||
@@ -873,7 +870,6 @@ paths:
|
|||||||
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
|
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
|
||||||
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
|
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
|
||||||
占用、同柜组和容量门禁不变。
|
占用、同柜组和容量门禁不变。
|
||||||
支持将不同客户的入仓码加入同一柜次,明细及费用仍保留各自原客户归属。
|
|
||||||
柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。
|
柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。
|
||||||
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
|
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
|
||||||
parameters:
|
parameters:
|
||||||
@@ -890,7 +886,6 @@ paths:
|
|||||||
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
|
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
|
||||||
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
|
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
|
||||||
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同;
|
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同;
|
||||||
不同客户可加入同一柜次,关联订舱客户快照不作为客户准入限制。
|
|
||||||
已被其他有效柜次占用、国内仓不一致、
|
已被其他有效柜次占用、国内仓不一致、
|
||||||
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
|
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
|
||||||
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
|
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
|
||||||
@@ -1679,7 +1674,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
||||||
nullable: true
|
nullable: true
|
||||||
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
|
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。
|
||||||
customerId: {type: integer, format: int64, nullable: true}
|
customerId: {type: integer, format: int64, nullable: true}
|
||||||
receivingWarehouseId: {type: integer, format: int64, nullable: true}
|
receivingWarehouseId: {type: integer, format: int64, nullable: true}
|
||||||
destinationWarehouseId: {type: integer, format: int64, nullable: true}
|
destinationWarehouseId: {type: integer, format: int64, nullable: true}
|
||||||
@@ -2381,7 +2376,7 @@ components:
|
|||||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
|
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
|
||||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||||
planNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
||||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||||
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
|
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
|
||||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
||||||
@@ -2548,7 +2543,7 @@ components:
|
|||||||
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示}
|
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示}
|
||||||
orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示}
|
orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示}
|
||||||
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID}
|
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID}
|
||||||
loadingPlanNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', description: 装柜计划业务号,UI主展示}
|
loadingPlanNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', description: 装柜计划业务号,UI主展示}
|
||||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID}
|
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID}
|
||||||
cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示}
|
cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示}
|
||||||
containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示}
|
containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示}
|
||||||
@@ -2681,7 +2676,7 @@ components:
|
|||||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items]
|
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items]
|
||||||
properties:
|
properties:
|
||||||
id: {type: string}
|
id: {type: string}
|
||||||
planNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号}
|
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
|
||||||
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
||||||
bookingId: {type: string}
|
bookingId: {type: string}
|
||||||
containerNo: {type: string, nullable: true}
|
containerNo: {type: string, nullable: true}
|
||||||
@@ -2947,7 +2942,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
|
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
|
||||||
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
|
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
|
||||||
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。关联时移除格式空白并转大写,不校验ISO长度、结构或校验位,仍保证同一订舱内唯一;确认预装及创建PDA任务前必须补齐真实柜号,不校验ISO格式}
|
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和同一订舱内唯一性}
|
||||||
expectedVersion: {type: integer, minimum: 0}
|
expectedVersion: {type: integer, minimum: 0}
|
||||||
ContainerFeeAdjustRequest:
|
ContainerFeeAdjustRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ paths:
|
|||||||
summary: 修改预装运输信息
|
summary: 修改预装运输信息
|
||||||
description: >-
|
description: >-
|
||||||
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
|
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
|
||||||
containerNo 为 null 时保留当前柜号;首次录入或纠正时服务端移除格式空白并转大写,不校验 ISO 6346 长度、结构、设备类别或校验位,空白字符串非法且禁止清空;同一订舱内柜号必须唯一。
|
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
|
||||||
x-permission: platform:firstleg:preload:edit
|
x-permission: platform:firstleg:preload:edit
|
||||||
parameters:
|
parameters:
|
||||||
- {$ref: '#/components/parameters/PlanId'}
|
- {$ref: '#/components/parameters/PlanId'}
|
||||||
@@ -299,7 +299,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
planId: {$ref: '#/components/schemas/LongId'}
|
planId: {$ref: '#/components/schemas/LongId'}
|
||||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||||
preloadNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
||||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||||
cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用}
|
cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用}
|
||||||
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
||||||
@@ -356,7 +356,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
planId: {$ref: '#/components/schemas/LongId'}
|
planId: {$ref: '#/components/schemas/LongId'}
|
||||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||||
loadingListNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
||||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Ocean Booking API
|
title: Ocean Booking API
|
||||||
version: 2.2.0
|
version: 2.1.0
|
||||||
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. 放舱不要求上传订舱确认文件或提单文件。 Released transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
|
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released bookings and their file links are permanently read-only.
|
||||||
paths:
|
paths:
|
||||||
/api/platform/v1/ocean-bookings:
|
/api/platform/v1/ocean-bookings:
|
||||||
get:
|
get:
|
||||||
@@ -29,26 +29,10 @@ paths:
|
|||||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
||||||
responses: {'200': {description: Deleted}}
|
responses: {'200': {description: Deleted}}
|
||||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:
|
|
||||||
put:
|
|
||||||
summary: 放舱后维护预计到港
|
|
||||||
description: 仅允许RELEASED订舱按当前版本与幂等键维护estimatedArrivalAt,不得清空或早于预计开船时间;其他运输、预报、关系方、柜型与附件仍冻结,保存前后快照及操作人。不修改实际到港或计划到仓。
|
|
||||||
x-permission: platform:firstleg:ocean-booking:edit
|
|
||||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/EstimatedArrivalRequest'}}}}
|
|
||||||
responses: {'200': {description: 已保存的RELEASED订舱详情,包含当前版本和预计到港}}
|
|
||||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
|
|
||||||
put:
|
|
||||||
summary: Supplement manual actual cargo on a RELEASED booking
|
|
||||||
description: Only actual package count, gross weight and volume may change. Transport and declared cargo remain frozen. Requires expectedVersion, Idempotency-Key and a reason; saves before/after audit snapshots atomically. Null means not recorded, never zero or a fallback to declared cargo.
|
|
||||||
x-permission: platform:firstleg:ocean-booking:edit
|
|
||||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualCargoRequest'}}}}
|
|
||||||
responses: {'200': {description: Updated RELEASED detail}, '409': {description: State or version conflict}}
|
|
||||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||||
post:
|
post:
|
||||||
summary: Validate and permanently release a DRAFT
|
summary: Validate and permanently release a DRAFT
|
||||||
description: 放舱校验已保存的订舱业务资料,不以订舱确认文件或提单文件作为前置条件。 The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
|
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
|
||||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
||||||
responses: {'200': {description: RELEASED detail}}
|
responses: {'200': {description: RELEASED detail}}
|
||||||
@@ -105,7 +89,7 @@ paths:
|
|||||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
|
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
|
||||||
get:
|
get:
|
||||||
summary: Render the current saved booking as an export shipping instruction PDF
|
summary: Render the current saved booking as an export shipping instruction PDF
|
||||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved declared values; container requirements describe requested containers only. Count in words is generated from the declared count and packageUnit.
|
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
|
||||||
x-permission: platform:firstleg:ocean-booking:list
|
x-permission: platform:firstleg:ocean-booking:list
|
||||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||||
responses:
|
responses:
|
||||||
@@ -113,7 +97,7 @@ paths:
|
|||||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
|
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
|
||||||
get:
|
get:
|
||||||
summary: Render the current saved booking as a bill-of-lading confirmation PDF
|
summary: Render the current saved booking as a bill-of-lading confirmation PDF
|
||||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved manually entered actual values only; missing actual data displays — and never falls back to declared values. This is a confirmation copy; the externally issued bill of lading remains an uploaded attachment.
|
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts.
|
||||||
x-permission: platform:firstleg:ocean-booking:list
|
x-permission: platform:firstleg:ocean-booking:list
|
||||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||||
responses:
|
responses:
|
||||||
@@ -208,40 +192,12 @@ components:
|
|||||||
destinationPortName: {type: string, nullable: true}
|
destinationPortName: {type: string, nullable: true}
|
||||||
placeOfDelivery: {type: string, nullable: true}
|
placeOfDelivery: {type: string, nullable: true}
|
||||||
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
||||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 草稿可留空,放舱前必填;不得早于预计开船时间,关联柜次后作为到港日历及默认到仓日期来源}
|
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||||
remark: {type: string, nullable: true}
|
remark: {type: string, nullable: true}
|
||||||
freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID}
|
|
||||||
serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY}
|
|
||||||
packageUnit: {type: string, enum: [CARTONS, PCS, PALLETS], nullable: true}
|
|
||||||
cargoDescription: {type: string, maxLength: 1000, nullable: true}
|
|
||||||
marksAndNos: {type: string, maxLength: 1000, nullable: true}
|
|
||||||
declaredPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
|
|
||||||
declaredGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
|
||||||
declaredVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
|
||||||
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
|
|
||||||
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
|
||||||
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
|
||||||
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
|
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
|
||||||
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
|
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
|
||||||
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}}
|
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}}
|
||||||
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
|
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
|
||||||
EstimatedArrivalRequest:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [expectedVersion, estimatedArrivalAt]
|
|
||||||
properties:
|
|
||||||
expectedVersion: {type: integer, minimum: 0}
|
|
||||||
estimatedArrivalAt: {type: string, format: date-time}
|
|
||||||
ActualCargoRequest:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [expectedVersion, reason]
|
|
||||||
properties:
|
|
||||||
expectedVersion: {type: integer, minimum: 0}
|
|
||||||
reason: {type: string, minLength: 1, maxLength: 1000}
|
|
||||||
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
|
|
||||||
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
|
||||||
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
|
||||||
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
||||||
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
||||||
DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
|
DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
|
||||||
@@ -355,11 +311,9 @@ components:
|
|||||||
required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
|
required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
|
||||||
properties:
|
properties:
|
||||||
oceanBooking:
|
oceanBooking:
|
||||||
allOf:
|
|
||||||
- {$ref: '#/components/schemas/DraftData'}
|
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
|
bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||||
parties: {type: array}
|
parties: {type: array}
|
||||||
containerRequirements: {type: array}
|
containerRequirements: {type: array}
|
||||||
containers: {type: array, description: Existing containers allocated after port pickup linking}
|
containers: {type: array, description: Existing containers allocated after port pickup linking}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
|||||||
info:
|
info:
|
||||||
title: Truck Appointment API
|
title: Truck Appointment API
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking. 预约创建及派车不要求订舱附件。 Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion alone does not complete the appointment. All required containers must be allocated and confirmed departed from the domestic warehouse. Completion atomically creates approved ON_ACCOUNT container payable fees allocated by loaded volume and publishes their immutable snapshots through the existing Finance Outbox. Create, edit, submit and reschedule also recheck departure facts, covering late appointments. Zero amounts create no fee. Cancelled and exception appointments are excluded. RMB is the only appointment currency and maps to CNY in Finance.
|
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion alone does not complete the appointment. All required containers must be allocated and confirmed departed from the domestic warehouse. Completion atomically creates approved ON_ACCOUNT container payable fees allocated by loaded volume and publishes their immutable snapshots through the existing Finance Outbox. Create, edit, submit and reschedule also recheck departure facts, covering late appointments. Zero amounts create no fee. Cancelled and exception appointments are excluded. RMB is the only appointment currency and maps to CNY in Finance.
|
||||||
paths:
|
paths:
|
||||||
/api/platform/v1/truck-appointments:
|
/api/platform/v1/truck-appointments:
|
||||||
get:
|
get:
|
||||||
@@ -33,7 +33,7 @@ paths:
|
|||||||
/api/platform/v1/truck-appointments/candidates:
|
/api/platform/v1/truck-appointments/candidates:
|
||||||
get:
|
get:
|
||||||
summary: Query authoritative create-time ocean-booking candidates
|
summary: Query authoritative create-time ocean-booking candidates
|
||||||
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading number, excluding bookings that already have an active or completed truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have an active or completed truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
||||||
parameters:
|
parameters:
|
||||||
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
|
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
|
||||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||||
@@ -168,7 +168,7 @@ components:
|
|||||||
timestamp: {type: string, format: date-time}
|
timestamp: {type: string, format: date-time}
|
||||||
Candidate:
|
Candidate:
|
||||||
type: object
|
type: object
|
||||||
required: [oceanBookingId, oceanBookingNo, warehouseId]
|
required: [oceanBookingId, oceanBookingNo, warehouseId, bookingDocumentReady]
|
||||||
properties:
|
properties:
|
||||||
oceanBookingId: {type: integer, format: int64}
|
oceanBookingId: {type: integer, format: int64}
|
||||||
oceanBookingNo: {type: string}
|
oceanBookingNo: {type: string}
|
||||||
@@ -177,7 +177,7 @@ components:
|
|||||||
warehouseName: {type: string, nullable: true}
|
warehouseName: {type: string, nullable: true}
|
||||||
channelCode: {type: string, nullable: true}
|
channelCode: {type: string, nullable: true}
|
||||||
channelName: {type: string, nullable: true}
|
channelName: {type: string, nullable: true}
|
||||||
|
bookingDocumentReady: {type: boolean, enum: [true]}
|
||||||
DetailEnvelope:
|
DetailEnvelope:
|
||||||
type: object
|
type: object
|
||||||
required: [code, message, data, timestamp]
|
required: [code, message, data, timestamp]
|
||||||
@@ -221,7 +221,7 @@ components:
|
|||||||
required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]
|
required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]
|
||||||
properties:
|
properties:
|
||||||
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
|
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
|
||||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading number. 订舱附件不作为前置条件。}
|
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading and own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
|
||||||
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint. The persisted warehouse is frozen from the selected booking.}
|
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint. The persisted warehouse is frozen from the selected booking.}
|
||||||
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING with truckingScope DOMESTIC (国内拖车).}
|
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING with truckingScope DOMESTIC (国内拖车).}
|
||||||
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
||||||
@@ -235,9 +235,9 @@ components:
|
|||||||
required: [appointmentNo, carrierId, carrierName, attachmentCount]
|
required: [appointmentNo, carrierId, carrierName, attachmentCount]
|
||||||
description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent.
|
description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent.
|
||||||
properties:
|
properties:
|
||||||
appointmentNo: {type: string, pattern: '^TA[0-9]{12}$', example: TA202608180001, description: 派车单号;按北京时间创建日逐日编号}
|
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
|
||||||
oceanBookingId: {type: integer, format: int64, nullable: true}
|
oceanBookingId: {type: integer, format: int64, nullable: true}
|
||||||
oceanBookingNo: {type: string, nullable: true, pattern: '^OB[0-9]{12}$', example: OB202608180001}
|
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
|
||||||
warehouseId: {type: integer, format: int64, nullable: true}
|
warehouseId: {type: integer, format: int64, nullable: true}
|
||||||
warehouseName: {type: string, nullable: true}
|
warehouseName: {type: string, nullable: true}
|
||||||
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
||||||
|
|||||||
+2
-3
@@ -88,7 +88,7 @@ class ContainerControllerContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("OpenAPI声明平台级货柜路径、关联柜号格式放行及冻结规则")
|
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
|
||||||
void documentsContainerContract() throws Exception {
|
void documentsContainerContract() throws Exception {
|
||||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||||
.getContentAsString(StandardCharsets.UTF_8);
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
@@ -106,8 +106,7 @@ class ContainerControllerContractTest {
|
|||||||
.contains("未满配 requirementId")
|
.contains("未满配 requirementId")
|
||||||
.contains("containerNo 可空")
|
.contains("containerNo 可空")
|
||||||
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
||||||
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
|
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
|
||||||
.contains("运输信息、提柜补录、确认预装、创建PDA任务、装柜完成及跨服务事件统一不校验ISO格式")
|
|
||||||
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
||||||
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
||||||
.doesNotContain("ContainerTransportUpdateRequest")
|
.doesNotContain("ContainerTransportUpdateRequest")
|
||||||
|
|||||||
+3
-6
@@ -32,7 +32,7 @@ class InboundMeasurementReviewContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("测量审核不进入待客户确认,并声明幂等和字符串长ID")
|
@DisplayName("OpenAPI不包含待客户确认并声明幂等和字符串长ID")
|
||||||
void shouldDocumentReviewContract() throws Exception {
|
void shouldDocumentReviewContract() throws Exception {
|
||||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||||
.getContentAsString(StandardCharsets.UTF_8);
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
@@ -46,11 +46,8 @@ class InboundMeasurementReviewContractTest {
|
|||||||
.contains("name: inboundId")
|
.contains("name: inboundId")
|
||||||
.contains("schema: {type: string, pattern:")
|
.contains("schema: {type: string, pattern:")
|
||||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
||||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:");
|
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:")
|
||||||
// 约束仅针对测量审核;客户费用确认属于另一业务,允许独立存在。
|
.doesNotContain("待客户确认");
|
||||||
int start = openapi.indexOf(" /api/platform/v1/inbounds/{inboundId}/measurements/approve:");
|
|
||||||
int end = openapi.indexOf("\n /", start + 1);
|
|
||||||
assertThat(openapi.substring(start, end < 0 ? openapi.length() : end)).doesNotContain("待客户确认");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user