Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5ac966ce9 | ||
|
|
de96e47d74 | ||
|
|
5d71af1ac9 | ||
|
|
98939d9314 | ||
|
|
e60890e63e | ||
|
|
0c05c15e5a | ||
|
|
74f510a49b | ||
|
|
5783610154 | ||
|
|
8adaea75fa | ||
|
|
738b778edf | ||
|
|
2c3435adaf | ||
|
|
bbf8f2f792 | ||
|
|
5356a8c898 | ||
|
|
234216b3eb | ||
|
|
e2ad7857b4 | ||
|
|
632d35faaf | ||
|
|
538a63c635 | ||
|
|
6088ff10c2 | ||
|
|
6d04659ced | ||
|
|
044812cd90 | ||
|
|
ae361879e5 | ||
|
|
c442c9eaa7 | ||
|
|
1ea7b4ff12 | ||
|
|
0976b6ef0d | ||
|
|
0c6c55f6f1 | ||
|
|
4a0e86bd42 | ||
|
|
55436e3a0a | ||
|
|
c962f614e6 | ||
|
|
090591076f | ||
|
|
94595c810b | ||
|
|
6fe632f28e | ||
|
|
40a03978cd | ||
|
|
0af977bdef | ||
|
|
be79b90a4a | ||
|
|
337da6d67c | ||
|
|
b4ff441a70 | ||
|
|
9fb39604c6 | ||
|
|
6b2df287bd | ||
|
|
7a9ab27fd2 | ||
|
|
4d12b56cac | ||
|
|
91b6574d53 | ||
|
|
e5f9cdcf01 |
@@ -108,14 +108,14 @@
|
||||
|
||||
## 数据库访问与中文代码注释(所有服务及公共模块强制)
|
||||
|
||||
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
|
||||
- 本节适用于全部后端服务和公共模块;新增代码遵守下列规范,存量分层仅在影响本次正确性或用户明确要求时整改,不因局部修改迁移整条链路;以上为目标规范,不代表存量均已达标。
|
||||
- 生产代码的关系型数据库读写统一经 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 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
|
||||
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
|
||||
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
|
||||
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
|
||||
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
|
||||
- 本次新增或修改的手写方法及构造器须有简短中文业务注释,通常 1—3 行;接口与 Mapper 方法优先用中文 Javadoc,覆盖方法同样适用,关键规则就地说明。不补齐未修改或自动生成方法的注释。
|
||||
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
|
||||
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
|
||||
|
||||
@@ -132,7 +132,11 @@
|
||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
|
||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。
|
||||
- 局部逻辑使用 `-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、数据库或部署环境。
|
||||
- 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`。实际部署仍须当前对话明确授权。
|
||||
|
||||
Vendored
+35
-2
@@ -67,6 +67,8 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||
List<String> failedServices = []
|
||||
if (state.startsWith('RESULTS')) {
|
||||
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 ->
|
||||
int separator = item.indexOf('=')
|
||||
if (separator > 0) {
|
||||
@@ -103,6 +105,25 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
||||
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) {
|
||||
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
||||
}
|
||||
@@ -161,7 +182,9 @@ pipeline {
|
||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
|
||||
choice(name: 'TEST_SCOPE', choices: ['AUTO', 'MODULE'], description: 'AUTO 对已支持的局部变更执行关联测试;公共/未知变更自动完整模块回归;MODULE 显式完整模块回归')
|
||||
// 地址由当前 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 基础镜像')
|
||||
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
||||
}
|
||||
@@ -268,6 +291,7 @@ pipeline {
|
||||
selected = selected.unique()
|
||||
|
||||
env.SELECTED_SERVICES = selected.join(',')
|
||||
env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected)
|
||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||
if (!selected) {
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
@@ -334,16 +358,22 @@ pipeline {
|
||||
.findAll { it }
|
||||
.join(',')
|
||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||
String testPlan = "${pwd(tmp: true)}/ci-test-plan.json"
|
||||
lock(resource: 'firstleg-test-maven') {
|
||||
timeout(time: 50, unit: 'MINUTES') {
|
||||
sh """
|
||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||
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 plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}'
|
||||
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};下游单服务任务不再重复执行测试"
|
||||
env.TEST_GATE_PASSED = 'true'
|
||||
}
|
||||
}
|
||||
post {
|
||||
@@ -400,6 +430,9 @@ pipeline {
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
||||
: "${env.IMAGE_TAG} | ${summary}"
|
||||
if (env.TEST_GATE_PASSED == 'true') {
|
||||
currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}"
|
||||
}
|
||||
if (failed) {
|
||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||
}
|
||||
|
||||
+2
-1
@@ -49,7 +49,8 @@ pipeline {
|
||||
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
|
||||
// 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,16 @@
|
||||
## 本地构建
|
||||
|
||||
```powershell
|
||||
mvn -DskipTests validate
|
||||
node --test scripts/java-topology-contract.test.mjs
|
||||
mvn test
|
||||
# 日常按受影响模块检查;跨服务变更显式列入消费者。
|
||||
.\scripts\backend-check.ps1 -Modules order -Mode Compile
|
||||
.\scripts\backend-check.ps1 -Modules order
|
||||
# 根构建/拓扑变化等全量触发条件见 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 门禁和上述本地命令为准。
|
||||
|
||||
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||
|
||||
@@ -40,8 +40,14 @@ public class GlobalExceptionHandler {
|
||||
@Autowired(required = false)
|
||||
private MeterRegistry meterRegistry;
|
||||
|
||||
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
|
||||
@ExceptionHandler(BizException.class)
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
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.core.MethodParameter;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
@@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest {
|
||||
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
|
||||
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
@@ -5,7 +5,8 @@ spring:
|
||||
password: ${FINANCE_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
|
||||
# TEST 流程验收复用 Client 的示例图片,图片标注不可付款且不含真实收款信息。
|
||||
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.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
|
||||
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/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 }
|
||||
}
|
||||
@@ -52,6 +52,10 @@
|
||||
|
||||
换标直接出库的实物体积快照在 `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 与部署须逐项授权。
|
||||
|
||||
@@ -3442,7 +3442,7 @@ CREATE TABLE fl_mojia_measurement_receipt (
|
||||
CREATE TABLE fl_ocean_booking (
|
||||
id bigint unsigned NOT NULL COMMENT '海运订舱ID',
|
||||
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_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
|
||||
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||
@@ -3465,6 +3465,17 @@ CREATE TABLE fl_ocean_booking (
|
||||
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
|
||||
estimated_arrival_at datetime(3) 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_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
||||
@@ -3479,6 +3490,14 @@ CREATE TABLE fl_ocean_booking (
|
||||
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_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_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||
@@ -3565,6 +3584,8 @@ CREATE TABLE fl_ocean_booking_party (
|
||||
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
|
||||
contact_name varchar(128) 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),
|
||||
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,
|
||||
@@ -6026,9 +6047,49 @@ CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_book
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN
|
||||
IF OLD.status = 'RELEASED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||
BEGIN
|
||||
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
|
||||
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.estimated_arrival_at <=> OLD.estimated_arrival_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;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||
|
||||
@@ -417,6 +417,7 @@ CREATE TABLE ll_dispatch_order (
|
||||
id BIGINT 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 1 COMMENT '是否向内部散板需求池发布余位',
|
||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||
@@ -429,6 +430,7 @@ CREATE TABLE ll_dispatch_order (
|
||||
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
|
||||
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
|
||||
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
|
||||
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
|
||||
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
|
||||
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
|
||||
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
|
||||
@@ -4874,7 +4876,7 @@ CREATE TABLE ll_fleet_pickup_history (
|
||||
) 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 '原排车内部主键',
|
||||
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
|
||||
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
|
||||
@@ -4892,7 +4894,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
|
||||
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='原排车装车完成时的车辆归属证据';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
|
||||
CREATE TABLE ll_fleet_owned_business_record (
|
||||
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
|
||||
@@ -4917,7 +4919,7 @@ CREATE TABLE IF NOT EXISTS 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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
|
||||
CREATE TABLE ll_fleet_owned_business_command (
|
||||
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
|
||||
@@ -4938,7 +4940,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
|
||||
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||
CREATE TABLE ll_fleet_owned_business_history (
|
||||
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
|
||||
|
||||
@@ -11,6 +11,8 @@ enum BusinessFileCategory {
|
||||
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_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_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -78,9 +80,9 @@ enum BusinessFileCategory {
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||
}
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
|
||||
}
|
||||
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
||||
|
||||
@@ -4,9 +4,9 @@ info:
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
||||
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
|
||||
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
||||
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
||||
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
||||
@@ -126,7 +126,7 @@ components:
|
||||
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
||||
DispatchEvidenceType:
|
||||
type: string
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE]
|
||||
MetadataEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
|
||||
@@ -30,6 +30,8 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class DispatchEvidenceFileTest {
|
||||
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 int MAX_BYTES = 10 * 1024 * 1024;
|
||||
@TempDir Path root;
|
||||
@@ -40,9 +42,9 @@ class DispatchEvidenceFileTest {
|
||||
FileObjectEntity.class);
|
||||
}
|
||||
|
||||
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
@@ -64,6 +66,19 @@ class DispatchEvidenceFileTest {
|
||||
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 和签名。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
@@ -149,7 +164,7 @@ class DispatchEvidenceFileTest {
|
||||
|
||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
|
||||
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
|
||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
||||
String reviewStatus, String sourceStatus,
|
||||
boolean statementGenerated) {}
|
||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
||||
public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
|
||||
BigDecimal amount, String method, String referenceNo,
|
||||
List<String> voucherFileIds, long createdBy, String createdByName,
|
||||
LocalDateTime createdAt,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
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
@@ -0,0 +1,29 @@
|
||||
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)); }
|
||||
}
|
||||
+21
-62
@@ -1,10 +1,10 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||
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.ReviewRequest;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
||||
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -43,19 +44,19 @@ public class FinancePhase1OperationService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FirstlegFeeSourceClient firstleg;
|
||||
private final String internalToken;
|
||||
private final FinanceFeeSourceReadRepository feeSources;
|
||||
|
||||
/** 注入费用来源查询及现有业务依赖。 */
|
||||
@Autowired
|
||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FirstlegFeeSourceClient firstleg,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken,
|
||||
FinanceFeeSourceReadRepository feeSources) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.firstleg = firstleg;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this(jdbc, objectMapper, null, "");
|
||||
this.feeSources = feeSources;
|
||||
}
|
||||
|
||||
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
|
||||
@@ -162,6 +163,7 @@ public class FinancePhase1OperationService {
|
||||
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
||||
}
|
||||
|
||||
/** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */
|
||||
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
||||
String direction, String sourceStatus,
|
||||
String reviewStatus,
|
||||
@@ -177,34 +179,9 @@ public class FinancePhase1OperationService {
|
||||
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||
String normalizedSourceStatus = normalized(sourceStatus,
|
||||
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
||||
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
|
||||
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
|
||||
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);
|
||||
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
|
||||
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
|
||||
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
|
||||
}
|
||||
|
||||
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
||||
@@ -338,6 +315,7 @@ public class FinancePhase1OperationService {
|
||||
return after;
|
||||
}
|
||||
|
||||
/** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */
|
||||
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
|
||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||
long id = rs.getLong("id");
|
||||
@@ -355,33 +333,7 @@ public class FinancePhase1OperationService {
|
||||
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"), status, generated,
|
||||
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);
|
||||
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id));
|
||||
}
|
||||
|
||||
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
|
||||
@@ -437,7 +389,14 @@ public class FinancePhase1OperationService {
|
||||
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 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 Long jsonLong(JsonNode node, String field) {
|
||||
JsonNode value = node.path(field);
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -306,7 +307,14 @@ public class FinanceService {
|
||||
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 BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
|
||||
@@ -45,23 +45,9 @@ import java.util.UUID;
|
||||
public class FinanceSettlementService {
|
||||
private static final String CNY = "CNY";
|
||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||
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 static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
|
||||
private final JdbcTemplate jdbc;
|
||||
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceVoucherValidator voucherValidator;
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
@@ -71,8 +57,10 @@ public class FinanceSettlementService {
|
||||
@Autowired
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator,
|
||||
FinanceDocumentNumberGenerator documentNumbers) {
|
||||
FinanceDocumentNumberGenerator documentNumbers,
|
||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
|
||||
this.jdbc = jdbc;
|
||||
this.details = details;
|
||||
this.objectMapper = objectMapper;
|
||||
this.voucherValidator = voucherValidator;
|
||||
this.supplierValidator = supplierValidator;
|
||||
@@ -183,33 +171,21 @@ public class FinanceSettlementService {
|
||||
return getSettlement(settlementId);
|
||||
}
|
||||
|
||||
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
|
||||
public SettlementDetail getSettlement(long settlementId) {
|
||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||
Map<String, Object> row = one(
|
||||
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
|
||||
"结算单不存在", 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)
|
||||
List<Map<String, Object>> records = details.summary(settlementId);
|
||||
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
|
||||
SettlementSummary summary = summary(records.get(0));
|
||||
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
|
||||
.stream().map(source -> line(source, summary)).toList();
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
|
||||
"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());
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
||||
.stream().map(this::transaction).toList());
|
||||
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
||||
transactions.addAll(jdbc.queryForList(
|
||||
"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());
|
||||
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
|
||||
.stream().map(this::transaction).toList());
|
||||
}
|
||||
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<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
|
||||
List<SettlementRevision> revisions = new ArrayList<>();
|
||||
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));
|
||||
@@ -477,8 +453,9 @@ public class FinanceSettlementService {
|
||||
text(row, "status"), true);
|
||||
}
|
||||
|
||||
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
|
||||
private SettlementTransaction transaction(Map<String, Object> row) {
|
||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
|
||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
|
||||
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(),
|
||||
text(row, "created_by_name"),
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -694,8 +695,9 @@ public class FinanceWalletService {
|
||||
timestamp(rs.getTimestamp("reviewed_at")));
|
||||
}
|
||||
|
||||
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
||||
private String hash(Object value) {
|
||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
||||
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||
}
|
||||
private String fileHash(MultipartFile file) {
|
||||
|
||||
+7
-2
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -521,9 +522,13 @@ public class ReconciliationStatementService {
|
||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||
}
|
||||
|
||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||
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_001", "幂等请求哈希失败"); }
|
||||
}
|
||||
|
||||
private static LocalDateTime localTime(Object value) {
|
||||
|
||||
@@ -17,6 +17,7 @@ class FinanceContractTest {
|
||||
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
|
||||
}
|
||||
|
||||
/** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */
|
||||
@Test
|
||||
void controllersUseFrozenFinancePaths() throws Exception {
|
||||
assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]);
|
||||
@@ -40,7 +41,8 @@ class FinanceContractTest {
|
||||
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
||||
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
||||
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
|
||||
assertTrue(operations.contains("bl.source_id=f.id"));
|
||||
String feeReads = Files.readString(Path.of("src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java"));
|
||||
assertTrue(feeReads.contains("bl.source_id=f.id"));
|
||||
assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")"));
|
||||
assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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,10 +9,13 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||
import com.baiyue.finance.service.FinanceCustomerAccountService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -40,6 +43,9 @@ import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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)
|
||||
class FinancePhase1OperationBehaviorTest {
|
||||
@@ -81,18 +87,20 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(response.page().total()).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 无效客户在进入持久化查询前拒绝。 */
|
||||
@Test
|
||||
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
}
|
||||
|
||||
/** 保留客户账户汇总的固定分页与金额口径。 */
|
||||
@Test
|
||||
void accountListReturnsFixedPagedDto() {
|
||||
AccountJdbc jdbc = new AccountJdbc();
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
|
||||
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
|
||||
@@ -112,10 +120,11 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
|
||||
}
|
||||
|
||||
/** 所有派生流水查询保留目标客户范围。 */
|
||||
@Test
|
||||
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
|
||||
AccountJdbc jdbc = new AccountJdbc();
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||
@@ -128,58 +137,24 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
|
||||
}
|
||||
|
||||
/** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */
|
||||
@Test
|
||||
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
|
||||
.thenReturn(1L);
|
||||
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
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());
|
||||
|
||||
void feeSourceListUsesFormalFieldsAndAllActiveFilters() {
|
||||
var reads = mock(FinanceFeeSourceReadRepository.class);
|
||||
var filters = java.util.Map.<String, Object>of("query", "current");
|
||||
when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false,
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters);
|
||||
when(reads.list(filters)).thenReturn(List.of());
|
||||
when(reads.count(filters)).thenReturn(3L);
|
||||
var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads);
|
||||
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
||||
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
||||
|
||||
assertThat(result.page()).satisfies(meta -> {
|
||||
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));
|
||||
assertThat(result.page().total()).isEqualTo(3);
|
||||
assertThat(result.page().page()).isEqualTo(2);
|
||||
verify(reads).list(filters);
|
||||
}
|
||||
|
||||
/** 流水返回业务单号和操作者快照。 */
|
||||
@Test
|
||||
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
@@ -197,7 +172,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,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||
@@ -211,18 +186,20 @@ class FinancePhase1OperationBehaviorTest {
|
||||
});
|
||||
}
|
||||
|
||||
/** 保留费用审核状态读取。 */
|
||||
@Test
|
||||
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
||||
}
|
||||
|
||||
/** 费用详情保持原客户与来源版本。 */
|
||||
@Test
|
||||
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
feeSourceJdbc("APPROVED", true, 2), objectMapper());
|
||||
feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
||||
|
||||
@@ -232,10 +209,11 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
||||
}
|
||||
|
||||
/** 业务来源费用必须回到所属系统调整。 */
|
||||
@Test
|
||||
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
||||
new BigDecimal("120.00"), "更正金额", 1);
|
||||
@@ -247,6 +225,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
(Object[]) any());
|
||||
}
|
||||
|
||||
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
|
||||
@Test
|
||||
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
|
||||
@@ -257,7 +236,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
||||
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
jdbc, objectMapper(), firstleg, "internal-token");
|
||||
jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
|
||||
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
||||
@@ -278,6 +257,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
(Object[]) any());
|
||||
}
|
||||
|
||||
/** 审核以当前操作版本更新并追加同一记录的审计。 */
|
||||
@Test
|
||||
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
@@ -289,7 +269,6 @@ class FinancePhase1OperationBehaviorTest {
|
||||
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.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),
|
||||
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
||||
@@ -301,7 +280,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
|
||||
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);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
||||
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
||||
@@ -311,6 +290,51 @@ class FinancePhase1OperationBehaviorTest {
|
||||
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 {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||
@@ -318,7 +342,6 @@ class FinancePhase1OperationBehaviorTest {
|
||||
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.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),
|
||||
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
|
||||
return jdbc;
|
||||
|
||||
@@ -93,7 +93,7 @@ class FinancePlatformListContractTest {
|
||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
|
||||
"operatorId", "operatorName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
|
||||
"createdBy", "createdByName");
|
||||
"createdBy", "createdByName", "transactionNo");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
|
||||
"operatorId", "operatorName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
|
||||
|
||||
@@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest {
|
||||
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 f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
||||
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,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("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
|
||||
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
|
||||
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
|
||||
@@ -258,6 +258,7 @@ class FinanceSettlementBehaviorTest {
|
||||
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
|
||||
.isEqualTo("PAYMENT");
|
||||
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"),
|
||||
any(), any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
@@ -522,6 +523,7 @@ class FinanceSettlementBehaviorTest {
|
||||
.isEqualTo("RECEIPT");
|
||||
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
|
||||
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
|
||||
@@ -623,8 +625,15 @@ class FinanceSettlementBehaviorTest {
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001");
|
||||
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(),
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
|
||||
}
|
||||
|
||||
private static FinanceSupplierValidator supplierValidator() {
|
||||
@@ -657,7 +666,7 @@ class FinanceSettlementBehaviorTest {
|
||||
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();
|
||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), 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 id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.service.FinanceSettlementService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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;
|
||||
@@ -12,9 +17,12 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceSettlementRequestHashTest {
|
||||
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
|
||||
@Test
|
||||
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment() {
|
||||
var service = new FinanceSettlementService(null, new ObjectMapper(), null, null, null);
|
||||
@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<>();
|
||||
|
||||
@@ -79,8 +79,8 @@
|
||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||
- 报关、订舱和拖车引用的供应商必须通过 `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` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港为可选计划,不作为放舱必填项;填写时不得早于预计开船时间。
|
||||
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱列表 `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`,提单文件继续归订舱。
|
||||
|
||||
+2
-1
@@ -67,10 +67,11 @@ public class PlatformColoadGroupController {
|
||||
idempotencyKey, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
/** 按入仓码返回同柜组业务编号及当前有效成员,保留平台只读权限边界。 */
|
||||
@GetMapping("/by-inbound-code/{inboundCode}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
@Operation(summary = "按入仓码查询同柜组业务摘要",
|
||||
description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。")
|
||||
description = "返回真实业务组号、业务状态、有效成员数及去重后的全部成员入仓码;不返回内部组ID。")
|
||||
public ApiResponse<BusinessSummary> byInboundCode(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
|
||||
|
||||
@@ -71,6 +71,7 @@ public final class ColoadGroupModels {
|
||||
|
||||
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
|
||||
public record BusinessSummary(
|
||||
String groupNo,
|
||||
String status,
|
||||
int memberCount,
|
||||
List<BusinessMember> members
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ public interface ColoadGroupMapper {
|
||||
@Param("inboundId") long inboundId);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus
|
||||
SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,group_row.group_status groupStatus
|
||||
FROM fl_inbound_order requested
|
||||
JOIN fl_coload_group_member member
|
||||
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
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,6 +59,7 @@ public final class ColoadPersistenceRows {
|
||||
@Setter
|
||||
public static class InboundGroupLookupRow {
|
||||
private Long groupId;
|
||||
private String groupNo;
|
||||
private String groupStatus;
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -47,6 +47,8 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
|
||||
@Resource
|
||||
private ColoadGroupMapper mapper;
|
||||
@Resource
|
||||
private com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository queryRepository;
|
||||
@Resource private PendingReviewOrderClient pendingReviewOrders;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
||||
@@ -118,10 +120,11 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
return requiredSnapshot(groupId);
|
||||
}
|
||||
|
||||
/** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */
|
||||
@Transactional(readOnly = true)
|
||||
public BusinessSummary queryByInboundCode(String inboundCode) {
|
||||
String code = normalizedInboundCode(inboundCode);
|
||||
var groups = mapper.groupsByInboundCode(code).stream()
|
||||
var groups = queryRepository.groupsByInboundCode(code).stream()
|
||||
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
|
||||
.toList();
|
||||
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
|
||||
@@ -131,7 +134,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
|
||||
long groupId = groups.get(0).getGroupId();
|
||||
String status = groups.get(0).getGroupStatus();
|
||||
var memberRows = mapper.effectiveMemberCodes(groupId);
|
||||
var memberRows = queryRepository.effectiveMemberCodes(groupId);
|
||||
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
|
||||
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|
||||
|| trim(row.getInboundCode()) == null)) {
|
||||
@@ -142,7 +145,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
throw conflict("同柜不可拆组存在重复入仓片段业务号");
|
||||
}
|
||||
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
|
||||
return new BusinessSummary(status, members.size(), members);
|
||||
return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members);
|
||||
}
|
||||
|
||||
private long requiredCurrentGroupId(String inboundCode) {
|
||||
|
||||
+29
-3
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
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;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
@@ -55,28 +56,34 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
|
||||
int claim(@Param("id") long id, @Param("worker") String worker,
|
||||
@Param("leaseUntil") LocalDateTime leaseUntil);
|
||||
|
||||
/** 只有尚未过期的当前所有者可以续租,过期执行者必须重新领取。 */
|
||||
@Update("""
|
||||
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}
|
||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int renew(@Param("id") long id, @Param("worker") String worker,
|
||||
@Param("leaseUntil") LocalDateTime leaseUntil);
|
||||
|
||||
/** 完成落库必须仍持有有效租约,拒绝过期或已易主的旧执行者。 */
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command
|
||||
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,
|
||||
failure_request_id=NULL,failure_trace_id=NULL
|
||||
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);
|
||||
|
||||
/** 失败回执也受当前有效租约保护,不能覆盖新执行者的状态。 */
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command
|
||||
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},
|
||||
failure_request_id=#{requestId},failure_trace_id=#{traceId}
|
||||
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,
|
||||
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
|
||||
@@ -97,13 +104,32 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
|
||||
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
|
||||
@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 INTO fl_loading_completion_checkpoint
|
||||
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
|
||||
VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage})
|
||||
ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3)
|
||||
SELECT id,#{tenantId},#{inboundId},#{sequence},#{stage}
|
||||
FROM fl_loading_completion_command
|
||||
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("tenantId") long tenantId,
|
||||
int checkpointCompleted(@Param("commandId") long commandId, @Param("worker") String worker,
|
||||
@Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId, @Param("sequence") int sequence,
|
||||
@Param("stage") String stage);
|
||||
}
|
||||
|
||||
+11
@@ -126,6 +126,17 @@ public class PlatformOceanBookingController {
|
||||
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@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)); }
|
||||
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
|
||||
@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')")
|
||||
@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); }
|
||||
|
||||
@@ -45,10 +45,12 @@ public final class OceanBookingDtos {
|
||||
String customerName, String warehouseName, String channelName) {}
|
||||
|
||||
public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
|
||||
@NotBlank @Size(max = 255) String partyName,
|
||||
@NotNull @Size(max = 255) String partyName,
|
||||
@Size(max = 1000) String address,
|
||||
@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,
|
||||
@NotBlank @Size(max = 32) String containerKind,
|
||||
@@ -76,7 +78,31 @@ public final class OceanBookingDtos {
|
||||
LocalDateTime estimatedArrivalAt,
|
||||
@Size(max = 1000) String remark,
|
||||
@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 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,
|
||||
@Valid @NotNull DraftData data) {}
|
||||
|
||||
+11
@@ -40,6 +40,17 @@ public class OceanBookingEntity {
|
||||
private LocalDateTime estimatedSailingAt;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
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 String createdByName;
|
||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||
|
||||
+2
@@ -17,4 +17,6 @@ public class OceanBookingPartyEntity {
|
||||
private String address;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String contactEmail;
|
||||
private String taxNo;
|
||||
}
|
||||
|
||||
+10
-1
@@ -25,8 +25,17 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
""")
|
||||
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
|
||||
@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},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},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}")
|
||||
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 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}")
|
||||
int deleteDraft(@Param("id") long id, @Param("version") int version);
|
||||
@Select("""
|
||||
|
||||
+4
@@ -54,6 +54,10 @@ public class OceanBookingRepository {
|
||||
public int touchDraft(long id, int version, long operatorId, String operatorName) {
|
||||
return bookings.touchDraft(id, 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) {
|
||||
return bookings.deleteDraft(id, version);
|
||||
}
|
||||
|
||||
+51
-8
@@ -46,7 +46,7 @@ public class OceanBookingPrintService {
|
||||
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
|
||||
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
|
||||
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
|
||||
fields(template, detail), List.of(emptyCargo()))));
|
||||
fields(template, detail), List.of(cargo(template, booking)))));
|
||||
try {
|
||||
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
@@ -75,14 +75,21 @@ public class OceanBookingPrintService {
|
||||
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
|
||||
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
|
||||
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
|
||||
fields.put("serviceTerm", "");
|
||||
fields.put("freightTerm", "");
|
||||
fields.put("totalPackagesInWords", "");
|
||||
fields.put("serviceTerm", text(booking.getServiceTerm()));
|
||||
fields.put("freightTerm", text(booking.getFreightTerm()));
|
||||
Integer count = SHIPPING_INSTRUCTION.equals(template) ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
|
||||
fields.put("totalPackagesInWords", packageWords(count, booking.getPackageUnit()));
|
||||
if (SHIPPING_INSTRUCTION.equals(template)) {
|
||||
fields.put("toCompany", text(booking.getBookingAgentName()));
|
||||
fields.put("jobNumber", text(booking.getBookingNo()));
|
||||
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
|
||||
fields.put("cargoQuantity", "");
|
||||
fields.put("cargoQuantity", detail.containerRequirements().stream()
|
||||
.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("bookingAgent", text(booking.getBookingAgentName()));
|
||||
fields.put("remark", text(booking.getRemark()));
|
||||
@@ -98,8 +105,43 @@ public class OceanBookingPrintService {
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static PrintPdfClient.CargoRow emptyCargo() {
|
||||
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
|
||||
/** 两类单据严格分别读取委、实数据,不以柜型容量或预报量代替实装量。 */
|
||||
private static PrintPdfClient.CargoRow cargo(String template, OceanBookingEntity booking) {
|
||||
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) {
|
||||
@@ -110,7 +152,8 @@ public class OceanBookingPrintService {
|
||||
private static String party(OceanBookingPartyEntity value) {
|
||||
if (value == null) return "";
|
||||
return List.of(text(value.getPartyName()), text(value.getAddress()),
|
||||
join(value.getContactName(), value.getContactPhone())).stream()
|
||||
join(value.getContactName(), value.getContactPhone()), text(value.getContactEmail()),
|
||||
text(value.getTaxNo()).isEmpty() ? "" : "TAX NO: " + text(value.getTaxNo())).stream()
|
||||
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
|
||||
+46
-2
@@ -145,6 +145,26 @@ public class OceanBookingService {
|
||||
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
|
||||
@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
|
||||
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
String action = "DELETE_DRAFT";
|
||||
@@ -209,7 +229,18 @@ 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 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) {
|
||||
validateCargo(data.declaredPackageCount(), data.declaredGrossWeightKg(), data.declaredVolumeCbm());
|
||||
validateCargo(data.actualPackageCount(), data.actualGrossWeightKg(), data.actualVolumeCbm());
|
||||
boolean hasCustomerId = data.customerId() != null;
|
||||
boolean hasCustomerName = StringUtils.hasText(data.customerName());
|
||||
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
|
||||
@@ -231,17 +262,19 @@ public class OceanBookingService {
|
||||
}
|
||||
}
|
||||
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,
|
||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|
||||
|| !StringUtils.hasText(booking.getVoyageNo()) || booking.getEstimatedSailingAt() == null
|
||||
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null
|
||||
|| booking.getShippingCompanyId() == null
|
||||
|| (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode()))
|
||||
|| (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode()))
|
||||
|| !StringUtils.hasText(booking.getPlaceOfDelivery())
|
||||
|| !StringUtils.hasText(booking.getCarrierCode())
|
||||
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|
||||
|| (parties != null && parties.stream().anyMatch(party -> !StringUtils.hasText(party.getPartyName())))
|
||||
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
|
||||
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
|
||||
}
|
||||
@@ -298,6 +331,17 @@ public class OceanBookingService {
|
||||
v.setPlaceOfDelivery(trim(d.placeOfDelivery()));
|
||||
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
|
||||
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) {
|
||||
@@ -370,7 +414,7 @@ public class OceanBookingService {
|
||||
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())); 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())); v.setContactEmail(trim(r.contactEmail())); v.setTaxNo(trim(r.taxNo())); 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) {
|
||||
try {
|
||||
|
||||
+6
-1
@@ -63,6 +63,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
""")
|
||||
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
/** 到港与日历统一取最新观测的实际记录,后续修正为更早时间时不能被历史最大值覆盖。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||
@@ -70,7 +71,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
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_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
||||
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
|
||||
MAX(arrival.actual_at) AS arrivedDestinationPortAt,
|
||||
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='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
||||
@@ -81,6 +82,10 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
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_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
|
||||
<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
|
||||
|
||||
+14
-3
@@ -11,16 +11,27 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class AppointmentForecastService {
|
||||
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;
|
||||
public AppointmentForecastService(AppointmentForecastMapper mapper){this.mapper=mapper;}
|
||||
private final com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources;
|
||||
/** 读取船节点及日历权威到港来源,不从调用端接收推算日期。 */
|
||||
public AppointmentForecastService(AppointmentForecastMapper mapper,
|
||||
com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources) {
|
||||
this.mapper=mapper; this.sources=sources;
|
||||
}
|
||||
/** 返回明确来源柜的到港事实,保留未知日期。 */
|
||||
public List<Forecast> batch(List<Long> ids) {
|
||||
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个不重复的柜次");
|
||||
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->{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -4,6 +4,7 @@ import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -12,6 +13,9 @@ import java.util.List;
|
||||
public class LoadingCompletionCommandRepository {
|
||||
@Resource private LoadingCompletionCommandMapper mapper;
|
||||
|
||||
/** 按事务提交后的命令标识读取唯一完成请求。 */
|
||||
public LoadingCompletionCommandEntity command(long id) { return mapper.selectById(id); }
|
||||
|
||||
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
|
||||
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
|
||||
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
|
||||
@@ -28,7 +32,13 @@ public class LoadingCompletionCommandRepository {
|
||||
public String checkpoint(long commandId, long tenantId, long inboundId) {
|
||||
return mapper.checkpoint(commandId, tenantId, inboundId);
|
||||
}
|
||||
public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) {
|
||||
mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage);
|
||||
/** 命令行锁覆盖租约检查与检查点写入,旧执行者不能在租约易主后覆盖新结果。 */
|
||||
@Transactional
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,77 @@ import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
/** Lease-based recovery worker for PDA completion commands. */
|
||||
/** 事务提交后立即处理 PDA 完成命令,并按持久化租约定时恢复。 */
|
||||
@Service
|
||||
public class LoadingCompletionWorker {
|
||||
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private final String workerId = "pda-completion-" + UUID.randomUUID();
|
||||
|
||||
@Resource private LoadingCompletionCommandRepository commands;
|
||||
@Resource private LoadingPickRepository picks;
|
||||
@Resource private LoadingPickService loadingPicks;
|
||||
@Resource(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
|
||||
public void dispatch() {
|
||||
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
|
||||
if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
loadingPicks.processCompletion(candidate, workerId);
|
||||
} catch (RuntimeException exception) {
|
||||
handleFailure(candidate, exception);
|
||||
}
|
||||
/** 唤醒只发生在受理事务提交后,异步执行不延长 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 handleFailure(LoadingCompletionCommandEntity command, RuntimeException 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}")
|
||||
public void dispatch() {
|
||||
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
|
||||
process(candidate, "scheduled", System.nanoTime());
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留租约、检查点和失败恢复语义,记录领取及实际处理耗时。 */
|
||||
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
|
||||
&& exception.getCause() != null ? exception.getCause() : exception;
|
||||
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
|
||||
@@ -53,7 +93,7 @@ public class LoadingCompletionWorker {
|
||||
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
|
||||
boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS;
|
||||
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
|
||||
boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt,
|
||||
boolean recorded = commands.fail(command.getId(), leaseOwner, terminal ? "FAILED" : "RETRYABLE", retryAt,
|
||||
stage, code, requestId, traceId);
|
||||
if (terminal && recorded) {
|
||||
picks.failCompletion(command.getPickTaskId());
|
||||
@@ -63,6 +103,7 @@ public class LoadingCompletionWorker {
|
||||
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
|
||||
}
|
||||
|
||||
/** 仅将已明确不可重试的本地业务门禁判为终态。 */
|
||||
private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) {
|
||||
if (!(cause instanceof BizException business)) {
|
||||
return false;
|
||||
|
||||
@@ -56,6 +56,7 @@ import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -87,6 +88,8 @@ public class LoadingPickService {
|
||||
@Resource
|
||||
private LoadingCompletionCommandRepository completionCommands;
|
||||
@Resource
|
||||
private ApplicationEventPublisher completionEvents;
|
||||
@Resource
|
||||
private TransactionTemplate completionTransaction;
|
||||
@Resource
|
||||
private SmartLoadingRepository smartLoadingRepository;
|
||||
@@ -552,6 +555,7 @@ public class LoadingPickService {
|
||||
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
|
||||
throw conflict("装柜完成命令状态已变化,请刷新后重试");
|
||||
}
|
||||
completionEvents.publishEvent(new CompletionAccepted(replay.getId(), System.nanoTime()));
|
||||
}
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
@@ -598,9 +602,14 @@ public class LoadingPickService {
|
||||
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
|
||||
throw conflict("PDA任务版本已变化,请刷新后复核");
|
||||
}
|
||||
// 提交后立即唤醒仍经持久化租约领取;回滚不得触发结算,定时扫描继续负责故障恢复。
|
||||
completionEvents.publishEvent(new CompletionAccepted(command.getId(), System.nanoTime()));
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
|
||||
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
|
||||
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
|
||||
|
||||
private void requireCompletionContainerNumber(long taskId) {
|
||||
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
@@ -613,7 +622,7 @@ public class LoadingPickService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Called only by the completion worker, after its durable lease has committed. */
|
||||
/** 仅在持久化租约领取成功后执行,远程结算不占用受理事务。 */
|
||||
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
||||
long taskId = command.getPickTaskId();
|
||||
inboundHoldGuard.requireTaskNotHeld(taskId);
|
||||
@@ -625,55 +634,55 @@ public class LoadingPickService {
|
||||
.toList();
|
||||
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
|
||||
List<GroupSnapshot> taskGroups = coloadGroups(owners);
|
||||
runCompletionStage("CABINET_EVIDENCE", () ->
|
||||
runCompletionStage(taskId, "CABINET_EVIDENCE", () ->
|
||||
cabinetEvidenceService.requireComplete(task.getContainerBookingId()));
|
||||
runCompletionStage("COMPLETED_EVENT_READY", () ->
|
||||
runCompletionStage(taskId, "COMPLETED_EVENT_READY", () ->
|
||||
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++) {
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
||||
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
||||
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
||||
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
|
||||
runCompletionStage(taskId, "OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
|
||||
owner.tenantId(), owner.inboundId()));
|
||||
if (!checkpointAtLeast(checkpoint, "READY")) {
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(),
|
||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(),
|
||||
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++) {
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
||||
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
||||
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
||||
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
|
||||
runCompletionStage(taskId, "WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
|
||||
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
|
||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
|
||||
}
|
||||
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
|
||||
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
|
||||
runCompletionStage(taskId, "INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
|
||||
owner.tenantId(), owner.inboundId(),
|
||||
command.getOperatorId(), command.getOperatorName(),
|
||||
"PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
|
||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1,
|
||||
"INBOUND_COMPLETED");
|
||||
}
|
||||
}
|
||||
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
|
||||
long transactionStarted = System.nanoTime();
|
||||
try {
|
||||
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
|
||||
} finally {
|
||||
log.info("pda_completion_transaction taskId={} durationMs={}", taskId,
|
||||
(System.nanoTime() - transactionStarted) / 1_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
|
||||
/** 远程结算完成后,仅在短事务内收敛 Firstleg 本地完成事实。 */
|
||||
private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
|
||||
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
|
||||
long taskId = task.getId();
|
||||
@@ -688,11 +697,11 @@ public class LoadingPickService {
|
||||
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
|
||||
command.getOperatorId(), command.getOperatorName());
|
||||
}
|
||||
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
||||
runCompletionStage(taskId, "PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
||||
command.getOperatorId(), command.getOperatorName()));
|
||||
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
||||
runCompletionStage(taskId, "COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
||||
for (GroupSnapshot group : taskGroups) {
|
||||
runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
|
||||
runCompletionStage(taskId, "COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
|
||||
task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|'
|
||||
+ group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName()));
|
||||
}
|
||||
@@ -750,6 +759,14 @@ 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,
|
||||
String requestHash, long operatorId) {
|
||||
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|
||||
@@ -764,11 +781,18 @@ public class LoadingPickService {
|
||||
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 {
|
||||
action.run();
|
||||
succeeded = true;
|
||||
} catch (RuntimeException 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -9,6 +9,25 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerCalendarSourceMapper {
|
||||
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,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 c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
|
||||
+2
@@ -11,6 +11,8 @@ public class ContainerCalendarSourceRepository {
|
||||
private final ContainerCalendarSourceMapper 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) {
|
||||
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
|
||||
|
||||
@@ -24,27 +24,32 @@ public class WorkflowTabService {
|
||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).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);
|
||||
}
|
||||
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).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();
|
||||
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
||||
if(!sources.isEmpty()) {
|
||||
try {
|
||||
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);
|
||||
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()));
|
||||
}
|
||||
if(overseas.size()!=sources.size()) throw unavailable();
|
||||
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
|
||||
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
|
||||
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
|
||||
} catch(BizException e) { throw e; } catch(Exception e) {
|
||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
|
||||
}
|
||||
}
|
||||
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
||||
for(var fact:facts) {
|
||||
var inbound=expected.get(fact.inboundId());
|
||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
|
||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+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));
|
||||
}
|
||||
@@ -56,11 +61,11 @@ public class WorkflowTabService {
|
||||
Set<String> keys=new LinkedHashSet<>();
|
||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||
boolean signed="DELIVERED".equals(process);
|
||||
// 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。
|
||||
boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded();
|
||||
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
|
||||
boolean clearanceStage=f.departed();
|
||||
if(!clearanceStage && !signed) {
|
||||
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
|
||||
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
|
||||
if(f.exportCustomsReady() || f.customsCompleted()) keys.add("EXPORT_CUSTOMS");
|
||||
}
|
||||
if(signed) keys.add("SIGNED");
|
||||
if(clearanceStage && !signed) {
|
||||
@@ -77,5 +82,9 @@ public class WorkflowTabService {
|
||||
return keys;
|
||||
}
|
||||
/** 下游缺失或归属异常不能生成部分数量。 */
|
||||
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
|
||||
private static BizException unavailable(String reason) {
|
||||
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
|
||||
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 (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
|
||||
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_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
|
||||
channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||
@@ -27,6 +27,17 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
|
||||
estimated_arrival_at DATETIME(3) 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_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
||||
@@ -44,6 +55,14 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
('DRAFT','RELEASED')),
|
||||
CONSTRAINT chk_ocean_booking_carrier_code CHECK
|
||||
(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
|
||||
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
|
||||
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||
@@ -110,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
|
||||
address VARCHAR(1000) NULL COMMENT '关系方地址快照',
|
||||
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
|
||||
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
|
||||
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
|
||||
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
||||
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
|
||||
@@ -211,8 +232,47 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||
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.estimated_arrival_at <=> OLD.estimated_arrival_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;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||
|
||||
@@ -34,6 +34,7 @@ paths:
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]}
|
||||
portArrivalAt: {type: string, format: date-time, nullable: true, description: 与预约日历相同的权威到港当地时间,最新有效实际到港优先,其次柜资料实际到港及预计到港;未知保持空}
|
||||
latest: {$ref: '#/components/schemas/Fact'}
|
||||
previous: {$ref: '#/components/schemas/Fact'}
|
||||
'401': {description: 内部身份缺失、错误或服务端未配置}
|
||||
|
||||
@@ -45,8 +45,8 @@ paths:
|
||||
summary: 按入仓码查询同柜组业务摘要
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
|
||||
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
||||
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。
|
||||
返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
||||
页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。
|
||||
parameters:
|
||||
- name: inboundCode
|
||||
in: path
|
||||
@@ -147,8 +147,9 @@ components:
|
||||
ColoadGroupBusinessSummary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [status, memberCount, members]
|
||||
required: [groupNo, status, memberCount, members]
|
||||
properties:
|
||||
groupNo: {type: string, pattern: "^CLG[0-9]{12}$", description: 真实同柜业务组号;与创建及重放结果一致}
|
||||
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
|
||||
memberCount: {type: integer, minimum: 1}
|
||||
members:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Ocean Booking API
|
||||
version: 2.1.0
|
||||
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.
|
||||
version: 2.2.0
|
||||
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 transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
|
||||
paths:
|
||||
/api/platform/v1/ocean-bookings:
|
||||
get:
|
||||
@@ -29,6 +29,14 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
||||
responses: {'200': {description: Deleted}}
|
||||
/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:
|
||||
post:
|
||||
summary: Validate and permanently release a DRAFT
|
||||
@@ -89,7 +97,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
|
||||
get:
|
||||
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. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
|
||||
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.
|
||||
x-permission: platform:firstleg:ocean-booking:list
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||
responses:
|
||||
@@ -97,7 +105,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
|
||||
get:
|
||||
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. Container requirements remain a container summary and are not treated as actual cargo facts.
|
||||
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.
|
||||
x-permission: platform:firstleg:ocean-booking:list
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||
responses:
|
||||
@@ -192,12 +200,33 @@ components:
|
||||
destinationPortName: {type: string, nullable: true}
|
||||
placeOfDelivery: {type: string, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 可选预计到港时间;放舱不要求填写,填写时不得早于预计开船时间}
|
||||
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'}}
|
||||
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}}}
|
||||
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}}}
|
||||
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: 统一货重上限}}}
|
||||
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}}}
|
||||
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]}
|
||||
@@ -311,6 +340,8 @@ components:
|
||||
required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
|
||||
properties:
|
||||
oceanBooking:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/DraftData'}
|
||||
type: object
|
||||
properties:
|
||||
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ class ColoadGroupOpenApiContractTest {
|
||||
.doesNotContain("destinationCode")
|
||||
.doesNotContain("expectedVersion")
|
||||
.doesNotContain("platform:firstleg:coload");
|
||||
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members");
|
||||
assertThat(summaryProperties).containsOnlyKeys("groupNo", "status", "memberCount", "members");
|
||||
assertThat(memberProperties).containsOnlyKeys("inboundCode");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -37,7 +37,7 @@ import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class})
|
||||
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class, com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository.class})
|
||||
class ColoadGroupServiceTest {
|
||||
@org.springframework.context.annotation.Configuration
|
||||
static class Numbers {
|
||||
@@ -71,6 +71,7 @@ class ColoadGroupServiceTest {
|
||||
|
||||
var result = service.queryByInboundCode(" IN-A ");
|
||||
|
||||
assertThat(result.groupNo()).isEqualTo("CLG202610210001");
|
||||
assertThat(result.status()).isEqualTo("ACTIVE");
|
||||
assertThat(result.memberCount()).isEqualTo(2);
|
||||
assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B");
|
||||
@@ -574,6 +575,7 @@ class ColoadGroupServiceTest {
|
||||
private static InboundGroupLookupRow lookup(long groupId, String status) {
|
||||
InboundGroupLookupRow value = new InboundGroupLookupRow();
|
||||
value.setGroupId(groupId);
|
||||
value.setGroupNo("CLG202610210001");
|
||||
value.setGroupStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
+2
-1
@@ -86,7 +86,8 @@ class ColoadGroupSqlContractTest {
|
||||
.contains("requested.inbound_code=#{inboundCode}")
|
||||
.contains("requested.deleted=0")
|
||||
.contains("'ACTIVE','LOCKED','CONSUMED'")
|
||||
.doesNotContain("group_no", "order_id=#{");
|
||||
.contains("group_row.group_no groupNo")
|
||||
.doesNotContain("order_id=#{");
|
||||
assertThat(memberSql)
|
||||
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
|
||||
.contains("LEFT JOIN fl_inbound_order inbound")
|
||||
|
||||
+19
@@ -32,6 +32,7 @@ class PlatformOceanBookingControllerContractTest {
|
||||
Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("create", "platform:firstleg:ocean-booking:create"),
|
||||
Map.entry("updateActualCargo", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
|
||||
Map.entry("release", "platform:firstleg:ocean-booking:release"));
|
||||
@@ -47,6 +48,24 @@ class PlatformOceanBookingControllerContractTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void actualCargoContractExposesOnlySupplementFieldsAndReusesEditPermission() throws Exception {
|
||||
assertThat(method("updateActualCargo").getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value())
|
||||
.containsExactly("/{oceanBookingId}/actual-cargo");
|
||||
Map<String, Object> document = new org.yaml.snakeyaml.Yaml().load(
|
||||
Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")));
|
||||
var paths = (Map<String, Object>) document.get("paths");
|
||||
var endpoint = (Map<String, Object>) paths.get("/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo");
|
||||
var operation = (Map<String, Object>) endpoint.get("put");
|
||||
assertThat(operation).containsEntry("x-permission", "platform:firstleg:ocean-booking:edit");
|
||||
var schemas = (Map<String, Object>) ((Map<String, Object>) document.get("components")).get("schemas");
|
||||
var request = (Map<String, Object>) schemas.get("ActualCargoRequest");
|
||||
assertThat((Map<String, Object>) request.get("properties")).containsOnlyKeys(
|
||||
"expectedVersion", "reason", "actualPackageCount", "actualGrossWeightKg", "actualVolumeCbm");
|
||||
assertThat((java.util.List<String>) request.get("required")).contains("expectedVersion", "reason");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathVariablesUseExplicitAggregateNames() {
|
||||
assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
|
||||
|
||||
+10
@@ -46,6 +46,16 @@ class OceanBookingMapperContractTest {
|
||||
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasedSupplementSqlCannotWriteTransportOrDeclaredCargo() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateActualCargo",
|
||||
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity.class, int.class, long.class, String.class)
|
||||
.getAnnotation(Update.class).value());
|
||||
assertThat(sql).contains("status='RELEASED' AND version=#{version}", "version=version+1",
|
||||
"actual_package_count=#{value.actualPackageCount}", "actual_gross_weight_kg=#{value.actualGrossWeightKg}", "actual_volume_cbm=#{value.actualVolumeCbm}")
|
||||
.doesNotContain("bill_of_lading_no=", "vessel_name=", "declared_", "warehouse_id=", "package_unit=");
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
|
||||
|
||||
+3
-3
@@ -27,8 +27,8 @@ class OceanBookingSqlContractTest {
|
||||
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
|
||||
.contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
|
||||
.doesNotContain("service_term")
|
||||
.doesNotContain("freight_term")
|
||||
.contains("service_term", "declared_package_count", "actual_package_count")
|
||||
.contains("freight_term", "contact_email", "tax_no")
|
||||
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
|
||||
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')")
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
|
||||
@@ -49,7 +49,7 @@ class OceanBookingSqlContractTest {
|
||||
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
|
||||
assertThat(sql())
|
||||
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
|
||||
.contains("OLD.status = 'RELEASED'")
|
||||
.contains("OLD.status = 'RELEASED'", "NEW.declared_package_count <=> OLD.declared_package_count", "NEW.bill_of_lading_no <=> OLD.bill_of_lading_no", "NEW.version = OLD.version + 1")
|
||||
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
|
||||
.contains("request_hash CHAR(64) NOT NULL")
|
||||
.contains("<> 'RELEASED' THEN")
|
||||
|
||||
+34
-1
@@ -49,7 +49,7 @@ class OceanBookingPrintServiceTest {
|
||||
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
|
||||
.containsEntry("shipper", "Shipper\nAddress")
|
||||
.containsEntry("oceanVesselVoyage", "Vessel / V001")
|
||||
.containsEntry("cargoQuantity", "—")
|
||||
.containsEntry("cargoQuantity", "2*40HQ")
|
||||
.containsEntry("totalPackagesInWords", "—")
|
||||
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
|
||||
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
|
||||
@@ -95,6 +95,39 @@ class OceanBookingPrintServiceTest {
|
||||
.containsEntry("totalPackagesInWords", "—");
|
||||
}
|
||||
|
||||
@Test void routesDeclaredAndActualCargoSeparatelyAndGeneratesWords() {
|
||||
Detail detail = detail("RELEASED");
|
||||
var booking = detail.oceanBooking();
|
||||
booking.setPackageUnit("CARTONS"); booking.setDeclaredPackageCount(500); booking.setActualPackageCount(480);
|
||||
booking.setDeclaredGrossWeightKg(new BigDecimal("15000")); booking.setDeclaredVolumeCbm(new BigDecimal("68"));
|
||||
booking.setActualGrossWeightKg(new BigDecimal("14320.125")); booking.setActualVolumeCbm(new BigDecimal("65.321"));
|
||||
booking.setCargoDescription("HOUSEHOLD GOODS"); booking.setMarksAndNos("N/M");
|
||||
booking.setServiceTerm("CY-CY"); booking.setFreightTerm("FREIGHT PREPAID");
|
||||
detail.parties().get(0).setContactEmail("ops@example.test"); detail.parties().get(0).setTaxNo("TEST-TAX");
|
||||
when(bookings.detail(10L)).thenReturn(detail);
|
||||
when(print.generateFreightA4Documents(any(), any(), any()))
|
||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1}));
|
||||
service.shippingInstruction(10L, "split"); service.billConfirmation(10L, "split");
|
||||
var capture = ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
|
||||
verify(print, times(2)).generateFreightA4Documents(any(), any(), capture.capture());
|
||||
var declared = capture.getAllValues().get(0).documents().get(0);
|
||||
var actual = capture.getAllValues().get(1).documents().get(0);
|
||||
assertThat(declared.cargoRows().get(0).quantity()).isEqualTo("500");
|
||||
assertThat(declared.cargoRows().get(0).grossWeight()).isEqualTo("15000.000 KGS");
|
||||
assertThat(declared.fields()).containsEntry("totalPackagesInWords", "SAY FIVE HUNDRED CARTONS ONLY")
|
||||
.containsEntry("serviceTerm", "CY-CY").containsEntry("freightTerm", "FREIGHT PREPAID");
|
||||
assertThat(declared.fields().get("shipper")).contains("ops@example.test", "TAX NO: TEST-TAX");
|
||||
assertThat(actual.cargoRows().get(0).quantity()).isEqualTo("480");
|
||||
assertThat(actual.cargoRows().get(0).grossWeight()).isEqualTo("14320.125 KGS");
|
||||
assertThat(actual.cargoRows().get(0).measurement()).isEqualTo("65.321 CBM");
|
||||
assertThat(actual.fields()).containsEntry("totalPackagesInWords", "SAY FOUR HUNDRED EIGHTY CARTONS ONLY");
|
||||
booking.setActualPackageCount(null); booking.setActualGrossWeightKg(null); booking.setActualVolumeCbm(null);
|
||||
service.billConfirmation(10L, "missing");
|
||||
verify(print).generateFreightA4Documents(any(), eq("missing"), capture.capture());
|
||||
assertThat(capture.getValue().documents().get(0).cargoRows().get(0).quantity()).isEqualTo("—");
|
||||
assertThat(capture.getValue().documents().get(0).fields()).containsEntry("totalPackagesInWords", "—");
|
||||
}
|
||||
|
||||
private static Detail detail(String status) {
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
|
||||
|
||||
+110
-5
@@ -231,6 +231,26 @@ class OceanBookingServiceTest {
|
||||
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
|
||||
}
|
||||
|
||||
/** 不录预计到港仍可放舱,其余资料及附件门禁保持有效。 */
|
||||
@Test
|
||||
void releasesWithoutEstimatedArrival() {
|
||||
stubMasterData();
|
||||
OceanBookingEntity draft = completeBooking("DRAFT", 3);
|
||||
OceanBookingEntity released = completeBooking("RELEASED", 4);
|
||||
draft.setEstimatedArrivalAt(null);
|
||||
released.setEstimatedArrivalAt(null);
|
||||
when(repository.find(1L)).thenReturn(draft, released);
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
Detail result = service.release(1L, 9L, "admin", "release-no-arrival", new ReleaseRequest(3, "确认放舱"));
|
||||
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(result.oceanBooking().getEstimatedArrivalAt()).isNull();
|
||||
verify(documents).requireActiveValidDocument(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCarrierSnapshotMismatchAndUnassociatedBookingAgent() {
|
||||
stubMasterData();
|
||||
@@ -386,6 +406,91 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
|
||||
OceanBookingEntity before = completeBooking("RELEASED", 3);
|
||||
before.setDeclaredPackageCount(500);
|
||||
before.setActualPackageCount(450);
|
||||
AtomicReference<OceanBookingEntity> stored = new AtomicReference<>(before);
|
||||
when(repository.find(1L)).thenAnswer(invocation -> stored.get());
|
||||
when(repository.updateActualCargo(any(), eq(3), eq(9L), eq("admin"))).thenAnswer(invocation -> {
|
||||
OceanBookingEntity after = invocation.getArgument(0);
|
||||
after.setVersion(4); stored.set(after); return 1;
|
||||
});
|
||||
var request = new ActualCargoRequest(3, 480, new BigDecimal("14000.125"), new BigDecimal("65.321"), "修正实装货量");
|
||||
var result = service.updateActualCargo(1L, 9L, "admin", "actual-save", request);
|
||||
assertThat(result.oceanBooking().getActualPackageCount()).isEqualTo(480);
|
||||
assertThat(result.oceanBooking().getDeclaredPackageCount()).isEqualTo(500);
|
||||
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
|
||||
assertThat(before.getActualPackageCount()).isEqualTo(450);
|
||||
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ACTUAL_CARGO");
|
||||
assertThat(history.getValue().getBeforeSnapshot()).contains("\"actualPackageCount\":450");
|
||||
assertThat(history.getValue().getAfterSnapshot()).contains("\"actualPackageCount\":480");
|
||||
assertThat(history.getValue().getReason()).isEqualTo("修正实装货量");
|
||||
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
|
||||
verify(repository, never()).replaceRequirements(anyLong(), any());
|
||||
verifyNoInteractions(documents, priceBooks, suppliers, warehouses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsActualCargoOnDraftOrStaleVersionAndRequiresReason() {
|
||||
OceanBookingEntity value = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(value);
|
||||
var request = new ActualCargoRequest(3, 100, null, null, "补录");
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-draft", request)).isInstanceOf(BizException.class);
|
||||
value.setStatus("RELEASED");
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-stale",
|
||||
new ActualCargoRequest(2, 100, null, null, "补录"))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-no-reason",
|
||||
new ActualCargoRequest(3, 100, null, null, " "))).isInstanceOf(BizException.class).hasMessageContaining("原因");
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-negative",
|
||||
new ActualCargoRequest(3, 100, new BigDecimal("-1"), null, "补录"))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-precision",
|
||||
new ActualCargoRequest(3, 100, null, new BigDecimal("1.0001"), "补录"))).isInstanceOf(BizException.class);
|
||||
when(repository.updateActualCargo(any(), anyInt(), anyLong(), any())).thenReturn(0);
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-concurrent", request)).isInstanceOf(BizException.class);
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayedActualCargoDoesNotWriteAgain() throws Exception {
|
||||
var request = new ActualCargoRequest(3, 100, null, null, "补录");
|
||||
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
var operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hash("UPDATE_ACTUAL_CARGO", 1L, request));
|
||||
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
|
||||
when(repository.claimOperation(1L, 9L, "UPDATE_ACTUAL_CARGO", "actual-replay", operation.getRequestHash())).thenReturn(false);
|
||||
when(repository.operation(9L, "UPDATE_ACTUAL_CARGO", "actual-replay")).thenReturn(operation);
|
||||
assertThat(service.updateActualCargo(1L, 9L, "admin", "actual-replay", request).oceanBooking().getVersion()).isEqualTo(4);
|
||||
verify(repository, never()).updateActualCargo(any(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesUnnamedPartyContactDraftButRequiresNamesBeforeRelease() throws Exception {
|
||||
var tree = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.valueToTree(partialDraft(null));
|
||||
tree.putArray("parties").addObject().put("partyType", "SHIPPER").put("partyName", "")
|
||||
.put("contactEmail", "ops@example.test").put("taxNo", "TEST-TAX");
|
||||
stubCreatedDraft();
|
||||
service.create(9L, "admin", "partial-party", new CreateRequest(0, objectMapper.treeToValue(tree, DraftData.class)));
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity>> capture = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceParties(anyLong(), capture.capture());
|
||||
assertThat(capture.getValue()).singleElement().satisfies(party -> {
|
||||
assertThat(party.getContactEmail()).isEqualTo("ops@example.test");
|
||||
assertThat(party.getTaxNo()).isEqualTo("TEST-TAX");
|
||||
});
|
||||
var draft = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(draft);
|
||||
var parties = parties(); parties.get(0).setPartyName("");
|
||||
when(repository.parties(1L)).thenReturn(parties);
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "missing-party", new ReleaseRequest(3, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
|
||||
verify(repository, never()).transition(anyLong(), any(), any(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
|
||||
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
|
||||
@@ -412,7 +517,7 @@ class OceanBookingServiceTest {
|
||||
|
||||
private static DraftData partialDraft(String remark) {
|
||||
return new DraftData(null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, remark, null, null);
|
||||
null, null, null, null, null, null, null, null, null, remark, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static DraftData completeDraft() {
|
||||
@@ -420,11 +525,11 @@ class OceanBookingServiceTest {
|
||||
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
"LAX", LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null,
|
||||
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
|
||||
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
|
||||
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null, null, null),
|
||||
new PartyRequest("CONSIGNEE", "收货人", null, null, null, null, null),
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null, null, null)),
|
||||
List.of(new ContainerRequirementRequest("40HQ", "DRY", 2,
|
||||
new BigDecimal("68"), new BigDecimal("19500"))));
|
||||
new BigDecimal("68"), new BigDecimal("19500"))), null, null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static OceanBookingEntity completeBooking(String status, int version) {
|
||||
|
||||
+7
-3
@@ -44,8 +44,9 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留批量读取,实际到港与日历采用相同的最新观测顺序。 */
|
||||
@Test
|
||||
void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception {
|
||||
void batchesActualContainerFactsWithLatestObservedArrivalAndWithoutEstimatedTimes() throws Exception {
|
||||
String sql = String.join("\n", CarrierScheduleQueryMapper.class
|
||||
.getMethod("selectContainerActualFacts", List.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
@@ -55,13 +56,16 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
.contains("f.actual_at IS NOT NULL")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
|
||||
.contains("MAX(arrival.actual_at) AS arrivedDestinationPortAt")
|
||||
.contains("a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL")
|
||||
.contains("ORDER BY a.observed_at DESC,a.id DESC LIMIT 1")
|
||||
.doesNotContain("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
|
||||
.contains("ORDER BY b.id")
|
||||
.doesNotContain("estimated_at");
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3);
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(5);
|
||||
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
+14
-2
@@ -9,8 +9,20 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentForecastServiceTest {
|
||||
/** 无船期预计观察时仍返回日历所用的柜级到港事实,供海外统一推算日期。 */
|
||||
@Test void returnsTheCalendarArrivalEvenWhenLatestForecastIsAbsent() {
|
||||
var mapper=mock(AppointmentForecastMapper.class);
|
||||
var sources=mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class);
|
||||
var arrival=LocalDateTime.of(2026,10,30,23,30);
|
||||
when(sources.byIds(List.of(1L))).thenReturn(List.of(
|
||||
new com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource(1L,"D0001","CONT1",
|
||||
null,null,null,null,arrival,"ACTUAL")));
|
||||
var result=new AppointmentForecastService(mapper,sources).batch(List.of(1L));
|
||||
assertThat(result.get(0).latest()).isNull();
|
||||
assertThat(result.get(0).portArrivalAt()).isEqualTo(arrival);
|
||||
}
|
||||
@Test void preservesMissingForecastAndSourceLocalTimesWithoutSynthesizingEta() {
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class));
|
||||
var observed=LocalDateTime.of(2026,10,10,12,0);
|
||||
var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null);
|
||||
var previous=new AppointmentForecastMapper.Fact(1L,10L,"carrier","REF-OLD",observed.minusDays(1),observed.plusDays(5),null);
|
||||
@@ -21,7 +33,7 @@ class AppointmentForecastServiceTest {
|
||||
verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper);
|
||||
}
|
||||
@Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() {
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class));
|
||||
for(var ids:List.of(List.<Long>of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L)))
|
||||
assertThatThrownBy(()->service.batch(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.DataSourceTransactionManager;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/** 用真实 Mapper SQL、事务代理和独立连接验证完成检查点的租约隔离。 */
|
||||
class LoadingCompletionCheckpointLeaseTest {
|
||||
private JdbcDataSource dataSource;
|
||||
private JdbcTemplate jdbc;
|
||||
private LoadingCompletionCommandMapper mapper;
|
||||
private LoadingCompletionCommandRepository commands;
|
||||
|
||||
/** 每个用例使用独立内存数据库,不访问共享环境。 */
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:completion-lease-" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=1000");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE fl_loading_completion_command(
|
||||
id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP,
|
||||
attempt_count INT,next_retry_at TIMESTAMP,updated_at TIMESTAMP,completed_at TIMESTAMP,
|
||||
failure_stage VARCHAR(40),failure_code VARCHAR(32),failure_request_id VARCHAR(64),failure_trace_id VARCHAR(64))
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE fl_loading_completion_checkpoint(
|
||||
command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),
|
||||
updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))
|
||||
""");
|
||||
jdbc.update("""
|
||||
INSERT INTO fl_loading_completion_command(id,command_status,lease_owner,lease_until,attempt_count,next_retry_at)
|
||||
VALUES(900,'PROCESSING','old-owner',DATEADD('HOUR',1,CURRENT_TIMESTAMP),1,CURRENT_TIMESTAMP)
|
||||
""");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(LoadingCompletionCommandMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LoadingCompletionCommandMapper.class);
|
||||
commands = repository(mapper);
|
||||
}
|
||||
|
||||
/** 生产 Repository 的注解事务必须真实生效,覆盖锁定与写入两条 SQL。 */
|
||||
private LoadingCompletionCommandRepository repository(LoadingCompletionCommandMapper targetMapper) {
|
||||
var target = new LoadingCompletionCommandRepository();
|
||||
ReflectionTestUtils.setField(target, "mapper", targetMapper);
|
||||
var proxy = new ProxyFactory(target);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),
|
||||
new AnnotationTransactionAttributeSource()));
|
||||
return (LoadingCompletionCommandRepository) proxy.getProxy();
|
||||
}
|
||||
|
||||
/** 模拟原执行者续租后暂停,租约过期并被新执行者领取推进,再恢复旧线程。 */
|
||||
@Test
|
||||
void pausedOldOwnerCannotDowngradeTheNewOwnersCompletedCheckpoint() throws Exception {
|
||||
var renewed = new CountDownLatch(1);
|
||||
var resumeOldOwner = new CountDownLatch(1);
|
||||
var executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var oldWrite = executor.submit(() -> {
|
||||
assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isTrue();
|
||||
renewed.countDown();
|
||||
assertThat(resumeOldOwner.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
return commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
|
||||
});
|
||||
assertThat(renewed.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
|
||||
assertThat(commands.claim(900, "new-owner", LocalDateTime.now().plusMinutes(10))).isTrue();
|
||||
assertThat(commands.checkpoint(900, "new-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue();
|
||||
resumeOldOwner.countDown();
|
||||
assertThat(oldWrite.get(10, TimeUnit.SECONDS)).isFalse();
|
||||
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "SETTLED")).isZero();
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(jdbc.queryForObject("SELECT lease_owner FROM fl_loading_completion_command WHERE id=900", String.class))
|
||||
.isEqualTo("new-owner");
|
||||
} finally {
|
||||
resumeOldOwner.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/** 锁定后的实际写入前,独立数据库连接仍不能取得同一命令行的锁。 */
|
||||
@Test
|
||||
void commandRowRemainsLockedAcrossTheLeaseCheckAndCheckpointInsert() {
|
||||
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
|
||||
doAnswer(call -> {
|
||||
assertThatThrownBy(() -> {
|
||||
try (var connection = dataSource.getConnection();
|
||||
var statement = connection.prepareStatement("SELECT id FROM fl_loading_completion_command WHERE id=900 FOR UPDATE NOWAIT")) {
|
||||
statement.executeQuery().close();
|
||||
}
|
||||
}).isInstanceOf(SQLException.class)
|
||||
.satisfies(error -> assertThat(((SQLException) error).getErrorCode()).isEqualTo(50200));
|
||||
return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
|
||||
call.getArgument(3), call.getArgument(4), call.getArgument(5));
|
||||
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY");
|
||||
}
|
||||
|
||||
/** 初次锁定检查后若租约失效,实际 INSERT 也必须拒绝,不能仅依赖前一次检查。 */
|
||||
@Test
|
||||
void checkpointInsertRechecksLeaseValidityAfterTheInitialLock() {
|
||||
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
|
||||
doAnswer(call -> {
|
||||
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
|
||||
return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
|
||||
call.getArgument(3), call.getArgument(4), call.getArgument(5));
|
||||
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isNull();
|
||||
}
|
||||
|
||||
/** 驱动将已持久化的幂等重放计为零行时,仍须以锁内当前有效租约确认。 */
|
||||
@Test
|
||||
void zeroAffectedRowsCanConfirmAnIdempotentCheckpointUnderTheSameValidLease() {
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
|
||||
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
|
||||
doAnswer(call -> {
|
||||
mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
|
||||
call.getArgument(3), call.getArgument(4), call.getArgument(5));
|
||||
return 0;
|
||||
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY");
|
||||
}
|
||||
|
||||
/** 已过期但尚未被重新领取的所有者也不能写检查点、续租或写命令终态。 */
|
||||
@Test
|
||||
void expiredLeaseCannotWriteCheckpointRenewFailOrComplete() {
|
||||
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
|
||||
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero();
|
||||
assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isFalse();
|
||||
assertThat(commands.fail(900, "old-owner", "FAILED", LocalDateTime.now(), "LEASE_LOST", "A0409", null, null)).isFalse();
|
||||
assertThat(commands.complete(900, "old-owner")).isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_completion_checkpoint", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT command_status FROM fl_loading_completion_command WHERE id=900", String.class))
|
||||
.isEqualTo("PROCESSING");
|
||||
}
|
||||
|
||||
/** 同一当前所有者的重复或迟到阶段也只能保持或推进,不能降低完成检查点。 */
|
||||
@Test
|
||||
void validOwnerCheckpointStagesAreMonotonic() {
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED")).isTrue();
|
||||
commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("SETTLED");
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue();
|
||||
commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED");
|
||||
commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
|
||||
}
|
||||
|
||||
/** 完成或失败后的命令不允许追加检查点,即使所有者与租期字段仍有旧值。 */
|
||||
@Test
|
||||
void terminalCommandCannotAcceptAnotherCheckpoint() {
|
||||
jdbc.update("UPDATE fl_loading_completion_command SET command_status='COMPLETED' WHERE id=900");
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
|
||||
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero();
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -31,6 +31,8 @@ class LoadingCompletionOwnerPersistenceTest {
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,deleted INT,package_count INT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR,picked_package_count INT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_completion_checkpoint(command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))");
|
||||
jdbc.execute("CREATE TABLE fl_loading_completion_command(id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO fl_loading_completion_command VALUES(900,'PROCESSING','owner-test',DATEADD('HOUR',1,CURRENT_TIMESTAMP))");
|
||||
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10)");
|
||||
row(1, 101, 50, 3, "PARTIAL", 2, 10, 0);
|
||||
row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history
|
||||
@@ -76,11 +78,11 @@ class LoadingCompletionOwnerPersistenceTest {
|
||||
|
||||
@Test
|
||||
void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() {
|
||||
checkpoints.checkpointCompleted(900, 101, 50, 1, "READY");
|
||||
checkpoints.checkpointCompleted(900, 101, 50, 1, "SETTLED");
|
||||
checkpoints.checkpointCompleted(900, 101, 50, 1, "INBOUND_COMPLETED");
|
||||
checkpoints.checkpointCompleted(900, 202, 60, 2, "INBOUND_COMPLETED");
|
||||
checkpoints.checkpointCompleted(900, 303, 70, 3, "READY");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "READY");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "SETTLED");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "INBOUND_COMPLETED");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 202, 60, 2, "INBOUND_COMPLETED");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 303, 70, 3, "READY");
|
||||
assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY");
|
||||
|
||||
+103
-1
@@ -8,22 +8,124 @@ import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(LoadingCompletionWorker.class)
|
||||
@SpringJUnitConfig({LoadingCompletionWorker.class, LoadingCompletionWorkerTest.TransactionEvents.class})
|
||||
class LoadingCompletionWorkerTest {
|
||||
/** 与生产事务装配一致,注册事务事件工厂,避免普通事件工厂立即调用监听器。 */
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
static class TransactionEvents {}
|
||||
|
||||
@MockBean private LoadingCompletionCommandRepository commands;
|
||||
@MockBean private LoadingPickRepository picks;
|
||||
@MockBean private LoadingPickService loadingPicks;
|
||||
@MockBean(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
|
||||
@jakarta.annotation.Resource private LoadingCompletionWorker worker;
|
||||
@jakarta.annotation.Resource private ApplicationEventPublisher events;
|
||||
|
||||
/** 真实事务同步回调必须在提交后才将完成工作交给执行器。 */
|
||||
@Test
|
||||
void committedAcceptanceStartsImmediatelyThroughTheDurableLease() {
|
||||
LoadingCompletionCommandEntity command = command(0);
|
||||
when(commands.command(900L)).thenReturn(command);
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
transactions().executeWithoutResult(status -> {
|
||||
events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
|
||||
verify(completionExecutor, never()).execute(any());
|
||||
verify(loadingPicks, never()).processCompletion(any(), any());
|
||||
});
|
||||
|
||||
ArgumentCaptor<Runnable> work = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(completionExecutor).execute(work.capture());
|
||||
verify(loadingPicks, never()).processCompletion(any(), any());
|
||||
work.getValue().run();
|
||||
|
||||
verify(commands).claim(eq(900L), any(String.class), any(LocalDateTime.class));
|
||||
verify(loadingPicks).processCompletion(eq(command), any(String.class));
|
||||
}
|
||||
|
||||
/** 回滚后的命令没有权威落库事实,不允许唤醒或执行结算。 */
|
||||
@Test
|
||||
void rolledBackAcceptanceNeverSchedulesCompletion() {
|
||||
transactions().executeWithoutResult(status -> {
|
||||
events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
verify(completionExecutor, never()).execute(any());
|
||||
verify(commands, never()).command(900L);
|
||||
}
|
||||
|
||||
/** 即时唤醒不能绕过定时执行者已取得的租约。 */
|
||||
@Test
|
||||
void immediateWakeupDoesNotProcessAnAlreadyLeasedCommand() {
|
||||
when(commands.command(900L)).thenReturn(command(0));
|
||||
worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
|
||||
ArgumentCaptor<Runnable> work = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(completionExecutor).execute(work.capture());
|
||||
work.getValue().run();
|
||||
verify(loadingPicks, never()).processCompletion(any(), any());
|
||||
}
|
||||
|
||||
/** 执行器拒绝时受理不报错,定时扫描仍可从原命令恢复。 */
|
||||
@Test
|
||||
void rejectedWakeupLeavesScheduledRecoveryAvailable() {
|
||||
doThrow(new RejectedExecutionException("busy")).when(completionExecutor).execute(any());
|
||||
LoadingCompletionCommandEntity command = command(0);
|
||||
when(commands.due(10)).thenReturn(List.of(command));
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
|
||||
worker.dispatch();
|
||||
verify(loadingPicks).processCompletion(eq(command), any(String.class));
|
||||
}
|
||||
|
||||
/** 同进程重领已到期命令也必须更换所有者,旧线程不能续租或落库。 */
|
||||
@Test
|
||||
void everyClaimUsesAnIndependentLeaseOwner() {
|
||||
LoadingCompletionCommandEntity command = command(0);
|
||||
when(commands.due(10)).thenReturn(List.of(command));
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
worker.dispatch();
|
||||
worker.dispatch();
|
||||
ArgumentCaptor<String> owners = ArgumentCaptor.forClass(String.class);
|
||||
verify(loadingPicks, times(2)).processCompletion(eq(command), owners.capture());
|
||||
assertThat(owners.getAllValues()).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
/** 隔离数据库依赖,仅用 Spring 的真实事务同步机制核对提交、回滚边界。 */
|
||||
private static TransactionTemplate transactions() {
|
||||
return new TransactionTemplate(new AbstractPlatformTransactionManager() {
|
||||
/** 为每次测试提供独立事务对象。 */
|
||||
@Override protected Object doGetTransaction() { return new Object(); }
|
||||
/** 测试事务不访问数据库。 */
|
||||
@Override protected void doBegin(Object transaction, TransactionDefinition definition) {}
|
||||
/** 让 Spring 正常触发提交同步回调。 */
|
||||
@Override protected void doCommit(DefaultTransactionStatus status) {}
|
||||
/** 让 Spring 正常触发回滚同步回调。 */
|
||||
@Override protected void doRollback(DefaultTransactionStatus status) {}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void transientFailureIsReleasedForBackoffRetry() {
|
||||
|
||||
@@ -50,6 +50,9 @@ import org.mockito.InOrder;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.context.event.RecordApplicationEvents;
|
||||
import org.springframework.test.context.event.ApplicationEvents;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@@ -77,7 +80,9 @@ import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringJUnitConfig(LoadingPickService.class)
|
||||
@RecordApplicationEvents
|
||||
class LoadingPickServiceTest {
|
||||
@Autowired private ApplicationEvents completionEvents;
|
||||
@MockBean
|
||||
private LoadingPickRepository repository;
|
||||
@MockBean
|
||||
@@ -154,6 +159,8 @@ class LoadingPickServiceTest {
|
||||
when(repository.activeBookingByTask(20L)).thenReturn(booking);
|
||||
when(completionCommands.insert(any())).thenReturn(1);
|
||||
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
|
||||
when(completionCommands.checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
|
||||
when(repository.inboundOwners(anyLong())).thenReturn(List.of(
|
||||
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
@@ -931,6 +938,7 @@ class LoadingPickServiceTest {
|
||||
assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
|
||||
verify(repository, never()).taskForUpdate(20L);
|
||||
verify(completionCommands, times(1)).insert(any());
|
||||
assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class).count()).isEqualTo(1);
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
@@ -997,6 +1005,8 @@ class LoadingPickServiceTest {
|
||||
|
||||
assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
|
||||
verify(completionCommands).resume(902L);
|
||||
assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class)
|
||||
.map(LoadingPickService.CompletionAccepted::commandId)).containsExactly(902L);
|
||||
verify(repository).resumeCompletion(20L);
|
||||
verify(completionCommands, never()).insert(any());
|
||||
}
|
||||
@@ -1089,7 +1099,7 @@ class LoadingPickServiceTest {
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString());
|
||||
verify(completionCommands, never()).checkpoint(eq(931L), eq(303L), eq(70L));
|
||||
verify(completionCommands, never()).checkpoint(eq(931L), anyLong(), anyLong(), anyInt(), anyString());
|
||||
verify(completionCommands, never()).checkpoint(eq(931L), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
verify(completionCommands).complete(931L, "worker-completed-checkpoints");
|
||||
}
|
||||
@@ -1109,6 +1119,31 @@ class LoadingPickServiceTest {
|
||||
verify(loadingListService, never()).publishCompletedEvent(anyLong());
|
||||
}
|
||||
|
||||
/** 任一检查点被租约拒绝后都不得继续下一业务步骤或最终完成柜次。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"READY", "SETTLED", "INBOUND_COMPLETED"})
|
||||
void lostLeaseAtCheckpointStopsFurtherCompletion(String rejectedStage) {
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(completionCommands.checkpoint(eq(940L), eq("old-owner"), eq(101L), eq(50L), eq(1), eq(rejectedStage)))
|
||||
.thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.processCompletion(completionCommand(940L, "lost-checkpoint"), "old-owner"))
|
||||
.isInstanceOf(LoadingPickService.CompletionProcessingException.class)
|
||||
.satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage())
|
||||
.isEqualTo("LEASE_LOST"));
|
||||
if ("READY".equals(rejectedStage)) {
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
if (!"INBOUND_COMPLETED".equals(rejectedStage)) {
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
|
||||
anyString(), anyString(), anyString());
|
||||
}
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(completionCommands, never()).complete(anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEntirelySkippedTaskCannotCreateACompletionCommand() {
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task());
|
||||
@@ -1271,8 +1306,8 @@ class LoadingPickServiceTest {
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
|
||||
anyString(), anyString(), anyString());
|
||||
verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY");
|
||||
verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(),
|
||||
verify(completionCommands).checkpoint(904L, "worker-failure", 101L, 50L, 1, "READY");
|
||||
verify(completionCommands, never()).checkpoint(eq(904L), anyString(), anyLong(), anyLong(), anyInt(),
|
||||
eq("SETTLED"));
|
||||
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
@@ -1300,7 +1335,7 @@ class LoadingPickServiceTest {
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
|
||||
anyString(), anyString(), anyString());
|
||||
verify(completionCommands, never()).checkpoint(anyLong(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
verify(completionCommands, never()).checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).publishCompletedEvent(anyLong());
|
||||
|
||||
+48
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
@@ -10,6 +11,53 @@ import java.time.LocalDateTime;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ContainerCalendarSourceMapperTest {
|
||||
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
|
||||
@Test
|
||||
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,query_id BIGINT,container_booking_id BIGINT," +
|
||||
"standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
|
||||
sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " +
|
||||
"(1,'D1','2026-10-29 08:00:00','ACTIVE'),(2,'D2','2026-10-29 08:00:00','ACTIVE')");
|
||||
sql.execute("INSERT INTO fl_carrier_schedule_query VALUES(1,0,'READY'),(2,0,'READY')");
|
||||
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
|
||||
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES " +
|
||||
"(1,1,1,'ARRIVED_DESTINATION_PORT','2026-10-23 14:00:00','2026-10-24 14:00:00')," +
|
||||
"(2,1,1,'ARRIVED_DESTINATION_PORT','2026-10-21 14:00:00','2026-10-25 14:00:00')," +
|
||||
"(3,1,1,'ARRIVED_DESTINATION_PORT',NULL,'2026-10-26 14:00:00')," +
|
||||
"(4,2,2,'ARRIVED_DESTINATION_PORT','2026-10-24 14:00:00','2026-10-25 14:00:00')," +
|
||||
"(5,2,2,'ARRIVED_DESTINATION_PORT','2026-10-22 14:00:00','2026-10-25 14:00:00')");
|
||||
}
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(ContainerCalendarSourceMapper.class);
|
||||
config.addMapper(CarrierScheduleQueryMapper.class);
|
||||
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var from = LocalDateTime.parse("2026-10-01T00:00:00");
|
||||
var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
|
||||
var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L));
|
||||
assertEquals(2, calendar.size());
|
||||
var explicit = session.getMapper(ContainerCalendarSourceMapper.class).byIds(java.util.List.of(1L,2L));
|
||||
assertEquals(calendar,explicit);
|
||||
assertEquals(2, workbench.size());
|
||||
for (int index = 0; index < calendar.size(); index++) {
|
||||
var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00");
|
||||
assertEquals(expected, calendar.get(index).arrivalAt());
|
||||
assertEquals("ACTUAL", calendar.get(index).arrivalType());
|
||||
assertEquals(expected, workbench.get(index).arrivedDestinationPortAt());
|
||||
assertEquals(calendar.get(index).arrivalAt().plusDays(2).toLocalDate(), workbench.get(index).arrivedDestinationPortAt().plusDays(2).toLocalDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
|
||||
@Test
|
||||
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
|
||||
|
||||
@@ -25,16 +25,19 @@ class WorkflowBoundaryTest {
|
||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class)
|
||||
.hasMessage("订单业务页签事实暂不可用,请稍后重试")
|
||||
.hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]");
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
var failure=new IllegalStateException("unavailable");
|
||||
when(downstream.read(any(),any())).thenThrow(failure);
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||
}
|
||||
|
||||
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
||||
@Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() {
|
||||
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */
|
||||
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
|
||||
var repository=mock(WorkflowFactRepository.class);
|
||||
var downstream=mock(LastlegWorkflowClient.class);
|
||||
var service=new WorkflowTabService(repository,downstream);
|
||||
@@ -42,12 +45,21 @@ class WorkflowBoundaryTest {
|
||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
||||
verifyNoInteractions(downstream);
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
||||
verifyNoInteractions(downstream);
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||
new WorkflowFacts.Overseas(11,1,false,false,false,false))));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
|
||||
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||
clearInvocations(downstream);
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,true)));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
|
||||
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
/** 取消回显、分柜隔离和资料页签回归。 */
|
||||
class WorkflowBusinessTest {
|
||||
/** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */
|
||||
/** 实际开船前保留出口报关,实际开船后再按清关资料进入海外待办。 */
|
||||
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
|
||||
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false);
|
||||
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false);
|
||||
@@ -18,9 +18,9 @@ class WorkflowBusinessTest {
|
||||
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
|
||||
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
|
||||
assertThat(WorkflowTabService.classify(completed,"LOADED",null))
|
||||
.containsExactly("SAILING_CLEARANCE");
|
||||
.containsExactly("EXPORT_CUSTOMS");
|
||||
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
|
||||
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
|
||||
.containsExactly("EXPORT_CUSTOMS");
|
||||
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
|
||||
.containsExactly("SAILING_CLEARANCE");
|
||||
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
|
||||
|
||||
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
|
||||
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有业务按无实际拼车货物、外配任务按已实际接入需求池货物分类;车辆归属仍读取装车快照,完整判定唯一见外配业务契约。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
|
||||
|
||||
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
||||
|
||||
@@ -56,8 +56,9 @@
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;默认发布字段随原排车创建/编辑保存,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
|
||||
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
|
||||
| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` |
|
||||
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` |
|
||||
@@ -77,7 +78,7 @@
|
||||
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 |
|
||||
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
|
||||
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
|
||||
| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 |
|
||||
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
||||
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
|
||||
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
|
||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜,并由 `ContainerWarehouseArrivalService` 统一解析:人工维护日期或已有计划冻结日期优先,未维护未建计划时读取 Firstleg 权威到港当地日期加两个自然日;缺失或同柜不一致时阻断,清关提柜、日历和人工/自动计划统一使用 `plannedDate`,默认日期不补零点,首个计划创建后按已有计划日期冻结;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java`、`service/LastlegDocumentNumberGenerator.java`;头程来源使用14位 `WC + yyyyMMdd + 四位流水`,建单经 `service/LoadingCompletedConsumer.java` 在来源柜锁内复用已有号,正式格式见工作区 `docs/业务单据编号契约.md` |
|
||||
| 订单查询与更新 | `service/LastlegOrderService.java` |
|
||||
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
|
||||
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
|
||||
@@ -85,8 +85,8 @@
|
||||
|
||||
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
||||
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
||||
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
||||
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
||||
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
||||
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
||||
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
||||
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
|
||||
|
||||
@@ -95,9 +95,9 @@
|
||||
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
|
||||
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
|
||||
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
|
||||
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
|
||||
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
|
||||
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
|
||||
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
|
||||
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
|
||||
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
|
||||
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
|
||||
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
|
||||
@@ -112,7 +112,7 @@
|
||||
## 事件建单规则
|
||||
|
||||
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
|
||||
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
|
||||
- 同一冻结来源柜 `sourceContainerBookingId` 共用一个14位 `WC + yyyyMMdd + 四位流水` 尾程单号;首个交接事件在来源柜行锁内取号,后续客户或跨日交接复用该柜已保存的编号,事件重放不取新号。不同来源柜复用真实集装箱号时仍分别编号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。真实 `containerNo` 继续保留作实箱快照,`cabinetNo` 仅为柜次展示,不替代稳定来源柜身份。
|
||||
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
|
||||
- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。
|
||||
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
|
||||
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
|
||||
|
||||
### 装车资料触发派送边界(2026-10-21)
|
||||
|
||||
`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。
|
||||
|
||||
### 外配业务原排车详情读取边界(2026-10-21)
|
||||
|
||||
`OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
|
||||
<artifactId>lastleg-service</artifactId>
|
||||
<properties>
|
||||
<!-- 定向 -Dtest 在 default-test 执行所选类,避免后置 SQL 执行再次运行同一列表。 -->
|
||||
<ci.sql-semantic.skip>false</ci.sql-semantic.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.5.3</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||
@@ -42,6 +46,7 @@
|
||||
<phase>test</phase>
|
||||
<goals><goal>test</goal></goals>
|
||||
<configuration>
|
||||
<skip>${ci.sql-semantic.skip}</skip>
|
||||
<includes><include>**/*SqlSemanticTest.java</include></includes>
|
||||
<reuseForks>false</reuseForks>
|
||||
</configuration>
|
||||
|
||||
@@ -72,11 +72,18 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|
||||
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|
||||
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
|
||||
'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService',
|
||||
'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController',
|
||||
'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source)
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort'
|
||||
|| [
|
||||
'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
|
||||
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
|
||||
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
|
||||
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
|
||||
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
|
||||
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
|
||||
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
|
||||
'dispatch must use fleet ports and guards, not its persistence mappers');
|
||||
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ public interface AppointmentForecastClient {
|
||||
record Request(List<Long> containerBookingIds) { }
|
||||
record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference,
|
||||
LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { }
|
||||
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis) { }
|
||||
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis,LocalDateTime portArrivalAt) { }
|
||||
@PostMapping("/internal/firstleg/appointment-forecasts/batch")
|
||||
ApiResponse<List<Forecast>> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request);
|
||||
}
|
||||
|
||||
+5
@@ -25,6 +25,11 @@ public interface AppointmentWorkDispatchPort {
|
||||
void requireDeparture(String dispatchNo);
|
||||
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
|
||||
String departureBlockReason(String dispatchNo);
|
||||
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
|
||||
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
|
||||
/** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */
|
||||
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
|
||||
List<ReceivingWindow> reviewEvidence(String dispatchNo);
|
||||
void removeItem(long dispatchId,long itemId);
|
||||
void requireTripBinding(String dispatchNo,long tripId);
|
||||
}
|
||||
|
||||
+13
-6
@@ -12,24 +12,31 @@ public interface WarehouseCalendarMapper {
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT * FROM (
|
||||
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo,
|
||||
SELECT o.source_container_booking_id sourceContainerBookingId,
|
||||
MAX(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.payload_json,'$.cabinetNo')),'null')) cabinetNo,
|
||||
MAX(o.container_no) containerNo,
|
||||
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
|
||||
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
|
||||
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
|
||||
AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p
|
||||
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
|
||||
WHERE po.source_container_booking_id=o.source_container_booking_id))
|
||||
AND MAX(o.arrival_time_version)>0
|
||||
THEN MAX(o.estimated_warehouse_at) END plannedAt,
|
||||
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
|
||||
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
|
||||
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version,
|
||||
(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
|
||||
WHERE po.source_container_booking_id=o.source_container_booking_id) planCount,
|
||||
FALSE canEdit
|
||||
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL
|
||||
FALSE canEdit,
|
||||
(SELECT CASE WHEN COUNT(p.planned_arrival_date)=COUNT(*) AND COUNT(DISTINCT p.planned_arrival_date)=1
|
||||
THEN MAX(p.planned_arrival_date) END FROM ll_inbound_plan p
|
||||
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
|
||||
WHERE po.source_container_booking_id=o.source_container_booking_id) plannedDate,
|
||||
FALSE isDefault
|
||||
FROM ll_order o LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id
|
||||
WHERE o.source_container_booking_id IS NOT NULL
|
||||
GROUP BY o.source_container_booking_id
|
||||
) c WHERE
|
||||
(c.plannedAt >= #{from} AND c.plannedAt < #{to})
|
||||
OR (c.plannedDate >= DATE(#{from}) AND c.plannedDate < DATE(#{to}))
|
||||
<if test="ids != null and !ids.isEmpty()">
|
||||
OR c.sourceContainerBookingId IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ public final class WarehouseCalendarModels {
|
||||
private WarehouseCalendarModels() { }
|
||||
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
|
||||
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
|
||||
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName,
|
||||
public record Entry(String sourceContainerBookingId, String cabinetNo, String containerNo, String warehouseName,
|
||||
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
|
||||
long planCount, boolean canEdit) { }
|
||||
long planCount, boolean canEdit, LocalDate plannedDate, boolean isDefault) { }
|
||||
}
|
||||
|
||||
+14
-4
@@ -10,8 +10,10 @@ import java.util.List;
|
||||
@Service
|
||||
public class WarehouseCalendarService {
|
||||
private final WarehouseCalendarRepository repository;
|
||||
private final com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals;
|
||||
/** 装配日历只读仓储查询。 */
|
||||
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; }
|
||||
public WarehouseCalendarService(WarehouseCalendarRepository repository,
|
||||
com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals) { this.repository = repository; this.arrivals=arrivals; }
|
||||
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
|
||||
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
|
||||
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
|
||||
@@ -20,8 +22,16 @@ public class WarehouseCalendarService {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
|
||||
var rows = repository.calendar(query);
|
||||
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
|
||||
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(),
|
||||
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
|
||||
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList();
|
||||
var resolved=arrivals.resolve(rows.stream().map(WarehouseCalendarModels.Entry::sourceContainerBookingId).toList())
|
||||
.stream().collect(java.util.stream.Collectors.toMap(
|
||||
com.baiyue.lastleg.dto.ContainerArrivalTimeView::sourceContainerBookingId,java.util.function.Function.identity()));
|
||||
return rows.stream().map(row -> {
|
||||
var arrival=resolved.get(row.sourceContainerBookingId());
|
||||
if(arrival==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到仓日期读取不完整");
|
||||
return new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.cabinetNo(), row.containerNo(),
|
||||
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
|
||||
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null,
|
||||
arrival.plannedDate(),arrival.isDefault());
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@FeignClient(name="order-service", contextId="lastlegOrderPreDepartureClient", path="/internal/order/v1/predeparture-orders")
|
||||
public interface OrderPreDepartureClient {
|
||||
@GetMapping
|
||||
ApiResponse<PageResult<Row>> page(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam(required=false) Long customerId, @RequestParam(required=false) String orderNo,
|
||||
@RequestParam(required=false) String inboundCode, @RequestParam int page, @RequestParam int size);
|
||||
record Row(Long orderId, Long customerId, String orderNo, String inboundCode, String mainStatus,
|
||||
String customerName, String lxCode, String receivingWarehouseName, String destinationWarehouseName,
|
||||
String destinationCode, Integer packageCount, BigDecimal volumeCbm, BigDecimal grossWeightKg,
|
||||
BigDecimal netWeightKg, LocalDateTime expectedArrivalAt, String containerNo, String cabinetNo,
|
||||
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
}
|
||||
-19
@@ -37,25 +37,6 @@ public class DeliveryTransferController {
|
||||
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups());
|
||||
}
|
||||
|
||||
/** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */
|
||||
@GetMapping("/dispatches/{dispatchNo}/delivery-arrangement")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
public ApiResponse<ArrangementPreview> arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo));
|
||||
}
|
||||
|
||||
/** 平台一次安排本车全部客户,身份只从认证上下文获取。 */
|
||||
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
|
||||
@Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单")
|
||||
public ApiResponse<CommandResult> transfer(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
|
||||
@Valid @RequestBody ArrangeDelivery request) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request));
|
||||
}
|
||||
|
||||
@PostMapping("/outbound-orders/one-click-dropship")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
|
||||
|
||||
+48
-3
@@ -126,6 +126,51 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
||||
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
|
||||
fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage);
|
||||
}
|
||||
var fact=createDocuments(context,actor,request.idempotencyKey(),businessHash,groups,inputs,external,coverages);
|
||||
return execution.recordArrangement(context,actor,identity,fact,false);
|
||||
}
|
||||
|
||||
/** 装车父事务内自动生成全车单据;未知派送资料和未确认费用保持未登记。 */
|
||||
@Override
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public ArrangementFact prepareForDeparture(Context context,Actor actor,String commandKey) {
|
||||
if(actor==null || blank(actor.operatorId()) || blank(commandKey) || commandKey.length()>255)
|
||||
throw conflict("缺少装车操作身份或原请求标识");
|
||||
if(context==null || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING
|
||||
|| !context.pickingReconciled() || context.loadingCompletedAt()==null || blank(context.loadingCompletionHash())
|
||||
|| blank(context.cargoHash()) || context.cargo()==null || context.cargo().isEmpty())
|
||||
throw conflict("请先完成真实拣货核平及装车确认");
|
||||
if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),context.warehouseId()))
|
||||
throw conflict("排车不属于当前绑定仓");
|
||||
var saved=repository.arrangement(context.dispatchId(),true);
|
||||
if(saved!=null) {
|
||||
if(!Objects.equals(saved.getOriginalCommandKey(),commandKey)
|
||||
|| !Objects.equals(saved.getCargoHash(),context.cargoHash())
|
||||
|| !Objects.equals(saved.getLoadingCompletionHash(),context.loadingCompletionHash()))
|
||||
throw conflict("本车已有不同装车命令生成的派送事实");
|
||||
var facts=departureFacts(context,true);
|
||||
if(!facts.depart().allowed())throw conflict(facts.depart().blockReason());
|
||||
return fact(saved);
|
||||
}
|
||||
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
|
||||
if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty())
|
||||
throw conflict("已存在关联单据但缺少完整安排事实,请核对");
|
||||
var groups=grouping.groups(context);var external=fees.external(context);
|
||||
var inputs=new HashMap<String,ArrangeGroup>();
|
||||
for(var group:groups) {
|
||||
String carrier=context.resources()==null || context.resources().vehicle()==null?null:context.resources().vehicle().carrierName();
|
||||
inputs.put(group.key(),new ArrangeGroup(group.key(),group.cargoHash(),null,null,null,carrier,null,null,
|
||||
null,null,null,null,null,external.get(group.customerId()).snapshotHash(),null,List.of(),List.of()));
|
||||
}
|
||||
String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),context.cargoHash(),context.loadingCompletionHash(),
|
||||
groups.stream().map(g->inputs.get(g.key())).toList()));
|
||||
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of());
|
||||
}
|
||||
|
||||
/** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */
|
||||
private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups,
|
||||
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
|
||||
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
|
||||
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
|
||||
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
|
||||
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
|
||||
@@ -169,9 +214,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
||||
var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId());
|
||||
arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash);
|
||||
arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash());
|
||||
arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId());
|
||||
arrangement.setOriginalCommandKey(commandKey);arrangement.setOriginalOperatorId(actor.operatorId());
|
||||
arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement);
|
||||
return execution.recordArrangement(context,actor,identity,fact(arrangement),false);
|
||||
return fact(arrangement);
|
||||
}
|
||||
|
||||
/** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */
|
||||
@@ -281,7 +326,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
||||
var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type());
|
||||
d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName());
|
||||
d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone());
|
||||
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt()));
|
||||
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(input.plannedDeliveryAt()==null?null:local(input.plannedDeliveryAt()));
|
||||
d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType());
|
||||
d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark());
|
||||
if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate());
|
||||
|
||||
+11
-11
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches")
|
||||
public class DispatchAggregateController {
|
||||
private final DispatchAggregateService service;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService loadingDeparture;
|
||||
|
||||
/** 注入本域应用服务。 */
|
||||
public DispatchAggregateController(DispatchAggregateService service) { this.service=service; }
|
||||
@@ -71,21 +71,21 @@ public class DispatchAggregateController {
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.LoadingPreview> loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 显式确认装车,不复用最后扫码或实际发车操作。 */
|
||||
/** 显式确认装车后自动准备派送并发车,原请求恢复最终派送中结果。 */
|
||||
@PostMapping("/{dispatchNo}/loading-complete")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')")
|
||||
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
|
||||
return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */
|
||||
@PostMapping("/{dispatchNo}/depart")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
|
||||
public ApiResponse<CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) {
|
||||
return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 本车证据先上传,主单引用随后走版本化编辑。 */
|
||||
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
/** 派送异常登记不触发签收或退货入库。 */
|
||||
@PostMapping("/{dispatchNo}/exceptions")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
|
||||
return ApiResponse.success(service.markException(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */
|
||||
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')")
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File> uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) {
|
||||
return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file));
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.dispatch.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 平台排车装车资料入口,与另一端共用五框和自动派送门禁。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
|
||||
public class DispatchLoadingDocumentController {
|
||||
private final DispatchLoadingDocumentService service;
|
||||
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
|
||||
public DispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
|
||||
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.get(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
|
||||
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
|
||||
@PutMapping("/{slot}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@PathVariable Slot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(DispatchActorFactory.platform(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 原上传仅允许当前有权限的原操作者恢复。 */
|
||||
@GetMapping("/uploads/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.recover(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
|
||||
@GetMapping("/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) {
|
||||
var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(value.content());
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets;
|
||||
public class DispatchQueryController {
|
||||
@Resource private DispatchAggregateService aggregateService;
|
||||
@Resource private DispatchBolPrintService printService;
|
||||
@Resource private com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
@@ -47,6 +48,14 @@ public class DispatchQueryController {
|
||||
return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query));
|
||||
}
|
||||
|
||||
/** 内部需求池沿独立只读权限,不授予销售整车客户及司机资料读取权限。 */
|
||||
@GetMapping("/capacity-demands")
|
||||
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Demand>> capacityDemands(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) {
|
||||
requirePlatform(user); return ApiResponse.success(capacityDemands.page(query));
|
||||
}
|
||||
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
@Operation(summary = "Get dispatch details and all pallet destinations")
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ public class SmartDispatchController {
|
||||
/** 读取实际候选,不在草稿域复制来源筛选逻辑。 */
|
||||
@GetMapping("/candidates")
|
||||
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
|
||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
|
||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String warehouseId,
|
||||
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
|
||||
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken,
|
||||
@RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) {
|
||||
|
||||
+3
@@ -9,6 +9,8 @@ public class DispatchAggregate {
|
||||
private Long id;
|
||||
private String dispatchNo;
|
||||
private String taskType;
|
||||
/** 是否向内部散板需求池发布本车余位。 */
|
||||
private Boolean publishLooseboardDemand = true;
|
||||
private Long tripId;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
@@ -24,6 +26,7 @@ public class DispatchAggregate {
|
||||
private String dispatchReviewJson;
|
||||
private String loadingReviewJson;
|
||||
private String resourcesJson;
|
||||
private String exceptionsJson;
|
||||
private String vehiclePlate;
|
||||
private String route;
|
||||
private LocalDateTime plannedDepartureAt;
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@ package com.baiyue.lastleg.dispatch.gateway;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */
|
||||
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
|
||||
public interface DispatchEvidenceGateway {
|
||||
enum Kind { VEHICLE, DRIVER }
|
||||
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION }
|
||||
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
|
||||
/** 上传只写本车、本类证据。 */
|
||||
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
|
||||
|
||||
+6
-2
@@ -29,7 +29,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
|
||||
private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; }
|
||||
/** 本车证据上传完成后仍以File返回元数据核对复合归属。 */
|
||||
@Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) {
|
||||
if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid();
|
||||
if(file==null || file.isEmpty() || file.getSize()>MAX || !supportedType(kind,file.getContentType()))throw invalid();
|
||||
var body=new MultipartBodyBuilder();
|
||||
try { body.part("file",new ByteArrayResource(file.getBytes()) {
|
||||
/** 保留用户文件名供审阅,不把路径当业务事实。 */
|
||||
@@ -80,9 +80,13 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
|
||||
var value=envelope.data();
|
||||
if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId())
|
||||
|| value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank()
|
||||
|| !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
|
||||
|| !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
|
||||
return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes());
|
||||
}
|
||||
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
|
||||
private static boolean supportedType(Kind kind,String contentType) {
|
||||
return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType));
|
||||
}
|
||||
/** 无效证据不能作为已核实资料。 */
|
||||
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
|
||||
/** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */
|
||||
|
||||
+22
-7
@@ -26,6 +26,9 @@ public interface DispatchAggregateMapper {
|
||||
String CURRENT_FILTER = """
|
||||
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if>
|
||||
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
||||
<if test='q.section == "VEHICLE_INFO"'> AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED')</if>
|
||||
<if test='q.section == "DISPATCH_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING'</if>
|
||||
<if test='q.section == "LOADING_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING'</if>
|
||||
<if test='q.reviewStatus != null and q.section == "DISPATCH_REVIEW"'> AND (
|
||||
""" + REVIEW_DISPATCH + ")=#{q.reviewStatus}</if>"
|
||||
+ "<if test='q.reviewStatus != null and q.section == \"LOADING_REVIEW\"'> AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}</if>";
|
||||
@@ -37,12 +40,12 @@ public interface DispatchAggregateMapper {
|
||||
|
||||
/** 首次创建整车所有权及初始版本,不写customer_id。 */
|
||||
@Insert("""
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,publish_looseboard_demand,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
||||
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
|
||||
dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at,
|
||||
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
|
||||
remark,created_by,created_at,updated_at)
|
||||
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
||||
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.publishLooseboardDemand},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
||||
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
|
||||
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
|
||||
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
|
||||
@@ -63,6 +66,17 @@ public interface DispatchAggregateMapper {
|
||||
+ " ORDER BY d.created_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
|
||||
List<DispatchAggregate> page(@Param("q") Search query);
|
||||
|
||||
/** 只读余位来源仅含勾选发布、未发车的有效派送排车,不混入取货和本土卡车。 */
|
||||
@Select("""
|
||||
<script>SELECT d.* FROM ll_dispatch_order d
|
||||
WHERE d.publish_looseboard_demand=TRUE AND d.task_type='DELIVERY' AND d.status='ACTIVE'
|
||||
AND d.departed_at IS NULL AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
|
||||
<if test='q.origin != null'>AND d.overseas_warehouse_name LIKE CONCAT('%',#{q.origin},'%')</if>
|
||||
<if test='q.destination != null'>AND d.route LIKE CONCAT('%',#{q.destination},'%')</if>
|
||||
ORDER BY d.planned_departure_at,d.created_at DESC,d.id DESC</script>
|
||||
""")
|
||||
List<DispatchAggregate> capacityDemands(@Param("q") com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query);
|
||||
|
||||
/** 分页总数复用完全相同筛选,不能用当前页长度替代。 */
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER + "</script>")
|
||||
long count(@Param("q") Search query);
|
||||
@@ -76,25 +90,26 @@ public interface DispatchAggregateMapper {
|
||||
@Update("""
|
||||
UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items},
|
||||
pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT',
|
||||
review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending},
|
||||
review_basis_hash=NULL,dispatch_review_json=#{dispatchReview},loading_review_json=#{loadingReview},
|
||||
record_version=record_version+1,updated_at=#{at}
|
||||
WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING'
|
||||
AND picked_package_count=0 AND shipped_package_count=0
|
||||
""")
|
||||
int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash,
|
||||
@Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages,
|
||||
@Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at);
|
||||
@Param("route") String route, @Param("dispatchReview") String dispatchReview, @Param("loadingReview") String loadingReview,
|
||||
@Param("at") java.time.LocalDateTime at);
|
||||
|
||||
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
|
||||
@Update("""
|
||||
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
|
||||
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},publish_looseboard_demand=#{r.publishLooseboardDemand},
|
||||
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
|
||||
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
|
||||
cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
|
||||
exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
|
||||
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
||||
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||
WHERE id=#{r.id} AND record_version=#{version}
|
||||
""")
|
||||
|
||||
+2
-1
@@ -50,7 +50,8 @@ public interface DispatchCandidatePageMapper {
|
||||
}
|
||||
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
|
||||
private static String filtered(boolean count) {
|
||||
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
|
||||
return " FROM ("+rows(count)+") candidates WHERE 1=1"
|
||||
+"<if test='query.warehouseId != null'> AND overseasWarehouseId=#{query.warehouseId}</if>"
|
||||
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
|
||||
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
|
||||
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
|
||||
|
||||
+5
@@ -93,6 +93,11 @@ public interface DispatchSourceMapper {
|
||||
@Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE")
|
||||
Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id);
|
||||
|
||||
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
|
||||
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
|
||||
|
||||
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
||||
@Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE")
|
||||
Long lockReturn(@Param("customer") String customer, @Param("id") Long id);
|
||||
|
||||
+10
-11
@@ -20,7 +20,7 @@ public class DispatchPdaController {
|
||||
@Resource private DispatchPdaQueryService queries;
|
||||
@Resource private DispatchPickingService picking;
|
||||
@Resource private DispatchAggregateService workflow;
|
||||
@Resource private DispatchDepartureService departure;
|
||||
@Resource private DispatchLoadingDepartureService loadingDeparture;
|
||||
|
||||
/** 批量读取当前绑定仓的整车作业。 */
|
||||
@GetMapping
|
||||
@@ -36,6 +36,13 @@ public class DispatchPdaController {
|
||||
return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo));
|
||||
}
|
||||
|
||||
/** 理货审核与后台共用同一轮次、版本和幂等账本,驳回须填写原因。 */
|
||||
@PostMapping("/{dispatchNo}/loading-review")
|
||||
public ApiResponse<DispatchContracts.CommandResult> loadingReview(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,@Valid @RequestBody DispatchCommands.ReviewCommand request) {
|
||||
return ApiResponse.success(workflow.review(DispatchActorFactory.pda(user),dispatchNo,true,request));
|
||||
}
|
||||
|
||||
/** 共用公共版本和全局请求键执行一次真实库存扫码。 */
|
||||
@PostMapping("/{dispatchNo}/items/{itemId}/pick")
|
||||
public ApiResponse<DispatchContracts.CommandResult> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -50,18 +57,10 @@ public class DispatchPdaController {
|
||||
return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo));
|
||||
}
|
||||
|
||||
/** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */
|
||||
/** 真实扫码核平后确认装车进入装车后,两端共用同一命令并等待五张装车照片。 */
|
||||
@PostMapping("/{dispatchNo}/loading-complete")
|
||||
public ApiResponse<DispatchContracts.CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
|
||||
return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request));
|
||||
}
|
||||
|
||||
/** 与平台共用实际发车服务,不执行第二次库存扣减。 */
|
||||
@PostMapping("/{dispatchNo}/depart")
|
||||
public ApiResponse<DispatchContracts.CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DispatchCommands.Depart request) {
|
||||
return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request));
|
||||
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request));
|
||||
}
|
||||
|
||||
/** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user