Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c3d36c20 |
@@ -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,11 +132,7 @@
|
||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
|
||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- 后端本地验证默认使用 `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/数据库,除非用户明确要求。
|
||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 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
+2
-35
@@ -67,8 +67,6 @@ 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) {
|
||||
@@ -105,25 +103,6 @@ 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']
|
||||
}
|
||||
@@ -182,9 +161,7 @@ pipeline {
|
||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
||||
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: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 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 前缀')
|
||||
}
|
||||
@@ -291,7 +268,6 @@ 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'
|
||||
@@ -358,22 +334,16 @@ 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
|
||||
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}'
|
||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||
"""
|
||||
}
|
||||
}
|
||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||
env.TEST_GATE_PASSED = 'true'
|
||||
}
|
||||
}
|
||||
post {
|
||||
@@ -430,9 +400,6 @@ 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(', ')}")
|
||||
}
|
||||
|
||||
+1
-2
@@ -49,8 +49,7 @@ pipeline {
|
||||
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||
// 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
}
|
||||
|
||||
|
||||
@@ -23,16 +23,11 @@
|
||||
## 本地构建
|
||||
|
||||
```powershell
|
||||
# 日常按受影响模块检查;跨服务变更显式列入消费者。
|
||||
.\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
|
||||
mvn -DskipTests validate
|
||||
node --test scripts/java-topology-contract.test.mjs
|
||||
mvn test
|
||||
```
|
||||
|
||||
开发中可通过 `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,14 +40,8 @@ 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,9 +5,6 @@ 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;
|
||||
@@ -42,37 +39,6 @@ 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,8 +5,7 @@ spring:
|
||||
password: ${FINANCE_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
# TEST 流程验收复用 Client 的示例图片,图片标注不可付款且不含真实收款信息。
|
||||
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
|
||||
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
|
||||
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}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'
|
||||
import { resolve, relative, basename } from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const moduleName = 'lastleg-service'
|
||||
const testClass = /(?:^Test.+|.+(?:Test|Tests|TestCase))\.java$/
|
||||
const words = source => new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
|
||||
const tables = source => new Set(source.match(/\bll_[a-z0-9_]+\b/g) || [])
|
||||
const writtenTables = source => new Set([...source.matchAll(/\b(?:UPDATE|INSERT\s+(?:IGNORE\s+)?INTO|DELETE\s+FROM)\s+(ll_[a-z0-9_]+)/gi)].map(match => match[1]))
|
||||
|
||||
// 按完整注解比较 SQL,避免只改 SELECT 时把同一 Mapper 内未改的全部写语句扩大为全域回归。
|
||||
function writeStatements(source) {
|
||||
const result = []
|
||||
for (const match of source.matchAll(/@(?:Insert|Update|Delete)\s*\(/g)) {
|
||||
let depth = 1, quote = '', index = match.index + match[0].length
|
||||
for (; index < source.length && depth; index++) {
|
||||
if (quote) {
|
||||
if (source.startsWith(quote, index)) { index += quote.length - 1; quote = '' }
|
||||
else if (source[index] === '\\' && quote.length === 1) index++
|
||||
} else if (source.startsWith('"""', index)) { quote = '"""'; index += 2 }
|
||||
else if (source[index] === '"' || source[index] === "'") quote = source[index]
|
||||
else if (source[index] === '(') depth++
|
||||
else if (source[index] === ')') depth--
|
||||
}
|
||||
if (depth) throw new Error('Cannot parse SQL annotation; use MODULE test scope')
|
||||
result.push(source.slice(match.index, index))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function changedWrites(node) {
|
||||
if (node.previousSource === undefined) return writtenTables(node.source)
|
||||
const before = writeStatements(node.previousSource), after = writeStatements(node.source)
|
||||
const changed = [...before.filter(sql => !after.includes(sql)), ...after.filter(sql => !before.includes(sql))]
|
||||
const oldRest = before.reduce((text, sql) => text.replace(sql, ''), node.previousSource)
|
||||
const newRest = after.reduce((text, sql) => text.replace(sql, ''), node.source)
|
||||
if (oldRest !== newRest) changed.push(oldRest, newRest)
|
||||
return writtenTables(changed.join('\n'))
|
||||
}
|
||||
|
||||
/** 宁可扩大范围,不用文件名猜测唯一测试;未识别的输入保留完整模块门禁。 */
|
||||
export function planTests({ modules, mode = 'AUTO', changes = [], sources = [], baseline = '' }) {
|
||||
const architectureScript = `${moduleName}/scripts/analyze-lastleg-dependencies`
|
||||
const staticChecks = changes.some(path => path.startsWith(architectureScript))
|
||||
? [`${architectureScript}.test.mjs`] : []
|
||||
const full = reason => ({ scope: 'MODULE', reason, modules, baseline, tests: [], staticChecks })
|
||||
if (mode !== 'AUTO' && mode !== 'MODULE') throw new Error('TEST_SCOPE must be AUTO or MODULE')
|
||||
if (mode === 'MODULE') return full('显式完整模块回归')
|
||||
if (!baseline) return full('没有成功测试基线')
|
||||
if (modules.length !== 1 || modules[0] !== moduleName) return full('多服务或尚未核对的服务采用完整模块回归')
|
||||
const prefix = `${moduleName}/`
|
||||
const relevant = changes.filter(path => path.startsWith(prefix))
|
||||
const shared = changes.some(path => path !== 'scripts/agents-index-contract.ps1' && !path.startsWith(prefix)
|
||||
&& /^(?:common-[^/]+\/|platform-bom\/|pom\.xml$|Jenkinsfile|deploy\/|config\/|scripts\/.*\.(?:mjs|sh|ps1)$)/.test(path))
|
||||
if (shared) return full('公共能力、发布或测试基础设施变更')
|
||||
if (!relevant.length) return full('补跑服务没有可验证的业务差异')
|
||||
const java = `${prefix}src/main/java/com/baiyue/lastleg/`
|
||||
const supported = path => path.startsWith(`${prefix}src/test/java/`)
|
||||
|| path.startsWith(`${prefix}src/main/resources/openapi/`)
|
||||
|| path.startsWith(architectureScript)
|
||||
|| (path.startsWith(java) && path.slice(java.length).includes('/')
|
||||
&& !/^(?:config|security|entity|dto|model|mapper|repository|service|client)\//.test(path.slice(java.length)))
|
||||
|| /\.(?:md)$/.test(path)
|
||||
if (relevant.some(path => !supported(path))) return full('共享模型、配置、SQL、资源或未识别路径变更')
|
||||
|
||||
const nodes = sources.map(({ path, source, previousSource }) => ({ path, source, previousSource,
|
||||
symbol: basename(path, '.java'), tokens: words(source), tables: tables(source),
|
||||
test: path.startsWith(`${prefix}src/test/java/`),
|
||||
name: `${source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''}.${basename(path, '.java')}` }))
|
||||
const byPath = new Map(nodes.map(node => [node.path, node]))
|
||||
const bySymbol = new Map()
|
||||
for (const node of nodes) {
|
||||
if (!bySymbol.has(node.symbol)) bySymbol.set(node.symbol, [])
|
||||
bySymbol.get(node.symbol).push(node)
|
||||
}
|
||||
const reverse = new Map(nodes.map(node => [node.path, new Set()]))
|
||||
for (const node of nodes) {
|
||||
// 包含反射字符串、静态 import、同包引用及测试夹具;重名类全部纳入。
|
||||
for (const token of node.tokens) for (const dependency of bySymbol.get(token) || []) {
|
||||
if (dependency !== node) reverse.get(dependency.path).add(node.path)
|
||||
}
|
||||
// 实现变化也要覆盖只依赖接口/基类的调用者,不能仅沿显式 import 反查。
|
||||
for (const match of node.source.matchAll(/\b(?:implements|extends)\s+([^{};]+)/g)) {
|
||||
for (const token of words(match[1])) for (const contract of bySymbol.get(token) || []) {
|
||||
if (contract !== node) reverse.get(node.path).add(contract.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
const seeds = new Set()
|
||||
const changedTables = new Set()
|
||||
for (const path of relevant.filter(path => path.endsWith('.java'))) {
|
||||
const node = byPath.get(path)
|
||||
if (node) {
|
||||
seeds.add(path)
|
||||
// 读查询变化不改变其他查询的数据;潜在写入变化则纳入同表读写消费者。
|
||||
if (!node.test) for (const table of changedWrites(node)) changedTables.add(table)
|
||||
} else {
|
||||
// 删除/移动旧类型时,仍按旧类名查现存消费者。
|
||||
const symbol = basename(path, '.java')
|
||||
const consumers = nodes.filter(consumer => consumer.tokens.has(symbol))
|
||||
if (!consumers.length) return full('删除类型缺少可追踪的现存消费者')
|
||||
for (const consumer of consumers) seeds.add(consumer.path)
|
||||
}
|
||||
}
|
||||
if (!seeds.size) return full('没有可追踪的 Java 行为差异')
|
||||
for (const node of nodes) if ([...node.tables].some(table => changedTables.has(table))) seeds.add(node.path)
|
||||
const affected = new Set(seeds)
|
||||
const queue = [...seeds]
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
for (const consumer of reverse.get(queue[index]) || []) if (!affected.has(consumer)) {
|
||||
affected.add(consumer); queue.push(consumer)
|
||||
}
|
||||
}
|
||||
const allTests = nodes.filter(node => node.test && testClass.test(basename(node.path))
|
||||
&& /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/.test(node.source))
|
||||
const contractChange = relevant.some(path => path.startsWith(`${prefix}src/main/resources/openapi/`))
|
||||
const candidates = allTests.filter(node => affected.has(node.path)
|
||||
|| /(?:Security|Isolation|MapperRegistrationArchitecture|QueryBinding)/.test(node.symbol)
|
||||
|| (contractChange && /(?:Contract|OpenApi|Schema|Architecture|Permission)/.test(node.symbol)))
|
||||
// 显式依赖真实环境的类仍由 MODULE/专项授权入口负责,不能把它们默认跳过算作执行成功。
|
||||
const optIn = node => /@(?:EnabledIf(?:SystemProperty|EnvironmentVariable)|Disabled(?:IfSystemProperty|IfEnvironmentVariable)?\b)/
|
||||
.test(node.source.slice(0, node.source.indexOf(`class ${node.symbol}`)))
|
||||
const tests = candidates.filter(node => !optIn(node))
|
||||
if (!tests.some(node => affected.has(node.path))) return full('没有匹配到行为测试,拒绝零测试通过')
|
||||
if (tests.some(node => !/^com\.[\w.$]+$/.test(node.name))) return full('测试类名无法安全传入 Maven')
|
||||
return { scope: 'AFFECTED', reason: 'Java 反向引用、接口实现、测试夹具、共享 SQL 表和相关静态合同',
|
||||
modules, baseline, tests: tests.map(node => node.name).sort(), totalTests: allTests.length, staticChecks,
|
||||
optInTests: candidates.filter(optIn).map(node => node.name).sort() }
|
||||
}
|
||||
|
||||
function walk(root) {
|
||||
const files = []
|
||||
for (const item of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = resolve(root, item.name)
|
||||
if (item.isDirectory()) files.push(...walk(path))
|
||||
else if (item.isFile() && item.name.endsWith('.java')) files.push(path)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/** 每个所选类必须有本轮实际执行的报告;允许上游零匹配不能变成整个门禁零测试。 */
|
||||
export function verifyReports(plan, reports) {
|
||||
if (plan.scope !== 'AFFECTED') return
|
||||
for (const name of plan.tests) {
|
||||
const matches = reports.filter(report => report.name === name || report.name.startsWith(`${name}$`))
|
||||
if (!matches.length || matches.some(report => report.failures || report.errors
|
||||
|| ['tests', 'skipped', 'failures', 'errors'].some(key => !Number.isInteger(report[key]) || report[key] < 0)
|
||||
|| report.skipped > report.tests)
|
||||
|| matches.reduce((sum, report) => sum + report.tests - report.skipped, 0) <= 0) {
|
||||
throw new Error(`Missing, skipped or failed fresh test report: ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
const [action, ...options] = args
|
||||
const values = {}
|
||||
for (let i = 0; i < options.length; i += 2) {
|
||||
if (!options[i].startsWith('--') || options[i + 1] === undefined) throw new Error('Expected --name value')
|
||||
values[options[i].slice(2)] = options[i + 1]
|
||||
}
|
||||
const root = process.cwd()
|
||||
const planFile = values.plan
|
||||
if (!planFile) throw new Error('--plan is required')
|
||||
if (action === 'plan') {
|
||||
const modules = (values.modules || '').split(',')
|
||||
if (modules.some(module => !/^[a-z][a-z0-9-]+$/.test(module))) throw new Error('Invalid modules')
|
||||
let baseline = values.base || ''
|
||||
let changes = []
|
||||
if (baseline) {
|
||||
if (!/^[0-9a-f]{7,40}$/i.test(baseline)) throw new Error('Invalid baseline commit')
|
||||
try {
|
||||
execFileSync('git', ['merge-base', '--is-ancestor', baseline, 'HEAD'], { stdio: 'pipe' })
|
||||
changes = execFileSync('git', ['diff', '--name-only', '-z', baseline, 'HEAD'], { encoding: 'utf8' }).split('\0').filter(Boolean)
|
||||
} catch { baseline = '' }
|
||||
}
|
||||
const sources = modules.length === 1 && modules[0] === moduleName
|
||||
? ['main', 'test'].flatMap(set => walk(resolve(root, moduleName, 'src', set, 'java')))
|
||||
.map(path => {
|
||||
const key = relative(root, path).replaceAll('\\', '/')
|
||||
const entry = { path: key, source: readFileSync(path, 'utf8') }
|
||||
if (baseline && changes.includes(key)) {
|
||||
try { entry.previousSource = execFileSync('git', ['show', `${baseline}:${key}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) }
|
||||
catch { entry.previousSource = '' }
|
||||
}
|
||||
return entry
|
||||
}) : []
|
||||
const plan = { ...planTests({ modules, mode: values.mode || 'AUTO', baseline, changes, sources }), startedAt: Date.now() }
|
||||
writeFileSync(planFile, JSON.stringify(plan))
|
||||
console.log(`CI test scope: ${plan.scope}; ${plan.reason}`)
|
||||
if (plan.scope === 'AFFECTED') console.log(`Selected ${plan.tests.length}/${plan.totalTests} test classes:\n${plan.tests.join('\n')}`)
|
||||
if (plan.optInTests?.length) console.log(`Explicit environment tests remain opt-in (not counted as passed): ${plan.optInTests.join(',')}`)
|
||||
return
|
||||
}
|
||||
const plan = JSON.parse(readFileSync(planFile, 'utf8'))
|
||||
if (action === 'args') {
|
||||
if (plan.scope === 'AFFECTED') process.stdout.write(`-Dtest=${plan.tests.join(',')} -Dsurefire.failIfNoSpecifiedTests=false -Dci.sql-semantic.skip=true`)
|
||||
} else if (action === 'checks') {
|
||||
for (const check of plan.staticChecks || []) {
|
||||
if (check !== `${moduleName}/scripts/analyze-lastleg-dependencies.test.mjs`) throw new Error('Unsupported static check')
|
||||
execFileSync(process.execPath, ['--test', '--test-concurrency=2', check], { stdio: 'inherit' })
|
||||
}
|
||||
} else if (action === 'verify') {
|
||||
const reports = []
|
||||
if (plan.scope === 'AFFECTED') for (const module of plan.modules) {
|
||||
const directory = resolve(root, module, 'target/surefire-reports')
|
||||
for (const filename of readdirSync(directory)) {
|
||||
if (!/^TEST-.*\.xml$/.test(filename)) continue
|
||||
const path = resolve(directory, filename)
|
||||
if (statSync(path).mtimeMs < plan.startedAt - 2000) continue
|
||||
const xml = readFileSync(path, 'utf8').match(/<testsuite\b([^>]+)>/)?.[1]
|
||||
if (!xml) continue
|
||||
const attrs = Object.fromEntries([...xml.matchAll(/([\w-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]))
|
||||
reports.push({ name: attrs.name || '', ...Object.fromEntries(['tests', 'failures', 'errors', 'skipped'].map(key => [key, Number(attrs[key] || 0)])) })
|
||||
}
|
||||
}
|
||||
verifyReports(plan, reports)
|
||||
console.log(`CI test reports verified: ${plan.scope}`)
|
||||
} else throw new Error(`Unknown action: ${action}`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 }
|
||||
}
|
||||
@@ -52,8 +52,6 @@
|
||||
|
||||
换标直接出库的实物体积快照在 `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与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
|
||||
|
||||
## 唯一入口与区域边界
|
||||
|
||||
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
|
||||
|
||||
@@ -417,7 +417,6 @@ 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 '起运海外仓编码快照',
|
||||
@@ -430,7 +429,6 @@ 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 '本趟主档或临时车辆司机的真实资源快照',
|
||||
@@ -4876,7 +4874,7 @@ CREATE TABLE ll_fleet_pickup_history (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
||||
|
||||
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
|
||||
CREATE TABLE ll_fleet_owned_business_ownership (
|
||||
CREATE TABLE IF NOT EXISTS 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 '真实车辆档案主键,临时外配为空',
|
||||
@@ -4894,7 +4892,7 @@ CREATE TABLE ll_fleet_owned_business_ownership (
|
||||
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
|
||||
|
||||
CREATE TABLE ll_fleet_owned_business_record (
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
|
||||
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
|
||||
@@ -4919,7 +4917,7 @@ CREATE TABLE ll_fleet_owned_business_record (
|
||||
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
|
||||
|
||||
CREATE TABLE ll_fleet_owned_business_command (
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
|
||||
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
|
||||
@@ -4940,7 +4938,7 @@ CREATE TABLE 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 ll_fleet_owned_business_history (
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
|
||||
|
||||
@@ -11,8 +11,6 @@ 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),
|
||||
@@ -80,9 +78,9 @@ enum BusinessFileCategory {
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||
}
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE)
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
||||
&& tenantId != 0) {
|
||||
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_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
|
||||
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, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE]
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
|
||||
MetadataEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
|
||||
@@ -30,8 +30,6 @@ 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;
|
||||
@@ -42,9 +40,9 @@ class DispatchEvidenceFileTest {
|
||||
FileObjectEntity.class);
|
||||
}
|
||||
|
||||
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
@@ -66,19 +64,6 @@ 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})
|
||||
@@ -164,7 +149,7 @@ class DispatchEvidenceFileTest {
|
||||
|
||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
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 transactionNo, String transactionType, LocalDate transactionDate,
|
||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
||||
BigDecimal amount, String method, String referenceNo,
|
||||
List<String> voucherFileIds, long createdBy, String createdByName,
|
||||
LocalDateTime createdAt,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.baiyue.finance.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */
|
||||
@Mapper
|
||||
public interface FinanceSettlementDetailMapper {
|
||||
String SUMMARY_SELECT =
|
||||
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
||||
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
||||
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
||||
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
||||
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
||||
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
||||
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
||||
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
||||
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
||||
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
||||
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
||||
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
||||
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
||||
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
||||
/** 读取平台结算主单及来源摘要。 */
|
||||
@Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}")
|
||||
List<Map<String, Object>> summary(@Param("settlementId") long settlementId);
|
||||
/** 读取该客户该结算的费用明细。 */
|
||||
@Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.id")
|
||||
List<Map<String, Object>> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
||||
@Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id")
|
||||
List<Map<String, Object>> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
||||
@Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id")
|
||||
List<Map<String, Object>> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId);
|
||||
/** 读取该客户结算修订历史。 */
|
||||
@Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id")
|
||||
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */
|
||||
@Repository
|
||||
public class FinanceSettlementDetailRepository {
|
||||
private final FinanceSettlementDetailMapper mapper;
|
||||
/** 注入结算详情持久化入口。 */
|
||||
public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; }
|
||||
/** 保留原数据库列名不区分大小写的读取语义。 */
|
||||
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
|
||||
return rows.stream().map(row -> { Map<String, Object> copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList();
|
||||
}
|
||||
/** 读取平台结算主单及来源摘要。 */
|
||||
public List<Map<String, Object>> summary(long settlementId) { return rows(mapper.summary(settlementId)); }
|
||||
/** 读取该客户该结算的费用明细。 */
|
||||
public List<Map<String, Object>> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); }
|
||||
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
||||
public List<Map<String, Object>> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); }
|
||||
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
||||
public List<Map<String, Object>> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); }
|
||||
/** 读取该客户结算修订历史。 */
|
||||
public List<Map<String, Object>> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); }
|
||||
}
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
@@ -438,14 +437,7 @@ public class FinancePhase1OperationService {
|
||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
||||
private 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 {
|
||||
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 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 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,7 +4,6 @@ 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;
|
||||
@@ -307,14 +306,7 @@ public class FinanceService {
|
||||
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
||||
};
|
||||
}
|
||||
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
||||
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 String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
|
||||
|
||||
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.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;
|
||||
@@ -45,9 +44,23 @@ 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 = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
|
||||
private static final String SETTLEMENT_SUMMARY_SELECT =
|
||||
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
||||
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
||||
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
||||
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
||||
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
||||
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
||||
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
||||
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
||||
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
||||
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
||||
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
||||
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
||||
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
||||
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceVoucherValidator voucherValidator;
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
@@ -57,10 +70,8 @@ public class FinanceSettlementService {
|
||||
@Autowired
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator,
|
||||
FinanceDocumentNumberGenerator documentNumbers,
|
||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
|
||||
FinanceDocumentNumberGenerator documentNumbers) {
|
||||
this.jdbc = jdbc;
|
||||
this.details = details;
|
||||
this.objectMapper = objectMapper;
|
||||
this.voucherValidator = voucherValidator;
|
||||
this.supplierValidator = supplierValidator;
|
||||
@@ -171,21 +182,33 @@ public class FinanceSettlementService {
|
||||
return getSettlement(settlementId);
|
||||
}
|
||||
|
||||
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
|
||||
public SettlementDetail getSettlement(long settlementId) {
|
||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||
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)
|
||||
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)
|
||||
.stream().map(source -> line(source, summary)).toList();
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
||||
.stream().map(this::transaction).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());
|
||||
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
||||
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
|
||||
.stream().map(this::transaction).toList());
|
||||
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());
|
||||
}
|
||||
List<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
|
||||
List<Map<String, Object>> audits = jdbc.queryForList(
|
||||
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
|
||||
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
|
||||
summary.customerId(), settlementId);
|
||||
List<SettlementRevision> revisions = new ArrayList<>();
|
||||
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));
|
||||
@@ -453,9 +476,8 @@ 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_no"), text(row, "transaction_type"),
|
||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
|
||||
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
||||
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
||||
text(row, "created_by_name"),
|
||||
@@ -501,13 +523,10 @@ public class FinanceSettlementService {
|
||||
return documentNumbers.next("PAY");
|
||||
}
|
||||
|
||||
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
String canonical = objectMapper.writer()
|
||||
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception failure) {
|
||||
throw error("FIN_001", "幂等请求哈希失败");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.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;
|
||||
@@ -695,9 +694,8 @@ public class FinanceWalletService {
|
||||
timestamp(rs.getTimestamp("reviewed_at")));
|
||||
}
|
||||
|
||||
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
||||
private String hash(Object value) {
|
||||
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||
}
|
||||
private String fileHash(MultipartFile file) {
|
||||
|
||||
+2
-7
@@ -10,7 +10,6 @@ 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;
|
||||
@@ -522,13 +521,9 @@ public class ReconciliationStatementService {
|
||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||
}
|
||||
|
||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||
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_001", "幂等请求哈希失败"); }
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||
}
|
||||
|
||||
private static LocalDateTime localTime(Object value) {
|
||||
|
||||
@@ -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", "transactionNo");
|
||||
"createdBy", "createdByName");
|
||||
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_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"),
|
||||
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"),
|
||||
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,7 +258,6 @@ 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());
|
||||
}
|
||||
@@ -523,7 +522,6 @@ 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
|
||||
@@ -625,15 +623,8 @@ 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 com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
|
||||
}
|
||||
|
||||
private static FinanceSupplierValidator supplierValidator() {
|
||||
@@ -666,7 +657,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_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
||||
import com.baiyue.finance.repository.FinanceSettlementDetailRepository;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceSettlementDetailMapperTest {
|
||||
/** 真实执行付款查询,业务号必须来自本客户本结算的流水,空号不以其他字段替代。 */
|
||||
@Test
|
||||
void paymentNumbersRemainScopedToCustomerAndSettlement() {
|
||||
var source = new DriverManagerDataSource("jdbc:h2:mem:settlement-number;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var jdbc = new JdbcTemplate(source);
|
||||
jdbc.execute("CREATE TABLE fin_settlement_payment (id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,transaction_no VARCHAR(32),transaction_type VARCHAR(32),transaction_date DATE,amount DECIMAL(18,2),method VARCHAR(32),reference_no VARCHAR(64),voucher_file_ids VARCHAR(255),created_by BIGINT,created_by_name VARCHAR(64),created_at TIMESTAMP,remark VARCHAR(255))");
|
||||
jdbc.update("INSERT INTO fin_settlement_payment (id,tenant_id,settlement_id,transaction_no,transaction_type,reference_no) VALUES (1,10,20,'PAY202610210001','PAYMENT','BANK-1'),(2,11,20,'PAY202610210002','PAYMENT','BANK-2'),(3,10,21,'PAY202610210003','PAYMENT','BANK-3'),(4,10,22,NULL,'PAYMENT','BANK-4')");
|
||||
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
|
||||
configuration.addMapper(FinanceSettlementDetailMapper.class);
|
||||
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
||||
var repository = new FinanceSettlementDetailRepository(session.getMapper(FinanceSettlementDetailMapper.class));
|
||||
var rows = repository.payments(10, 20);
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).get("transaction_no")).isEqualTo("PAY202610210001");
|
||||
assertThat(rows.get(0).get("reference_no")).isEqualTo("BANK-1");
|
||||
assertThat(repository.payments(11, 21)).isEmpty();
|
||||
assertThat(repository.payments(10, 22)).hasSize(1);
|
||||
assertThat(repository.payments(10, 22).get(0).get("transaction_no")).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.service.FinanceSettlementService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||
import com.baiyue.finance.service.FinanceService;
|
||||
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceSettlementRequestHashTest {
|
||||
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
|
||||
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
|
||||
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
|
||||
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("amount", 190); body.put("expectedVersion", 0);
|
||||
Map<String, Object> first = new LinkedHashMap<>();
|
||||
first.put("path", "/settlements/202/transactions"); first.put("method", "POST");
|
||||
first.put("customerId", 10L); first.put("body", body);
|
||||
Map<String, Object> reversedBody = new LinkedHashMap<>();
|
||||
reversedBody.put("expectedVersion", 0); reversedBody.put("amount", 190);
|
||||
Map<String, Object> second = new LinkedHashMap<>();
|
||||
second.put("body", reversedBody); second.put("customerId", 10L);
|
||||
second.put("method", "POST"); second.put("path", "/settlements/202/transactions");
|
||||
|
||||
String original = ReflectionTestUtils.invokeMethod(service, "hash", first);
|
||||
String reordered = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(reordered).isEqualTo(original);
|
||||
reversedBody.put("amount", 191);
|
||||
String changed = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(changed).isNotEqualTo(original);
|
||||
second.put("customerId", 11L); reversedBody.put("amount", 190);
|
||||
String otherCustomer = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(otherCustomer).isNotEqualTo(original);
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -67,11 +67,10 @@ 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,7 +71,6 @@ 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_no groupNo,group_row.group_status groupStatus
|
||||
SELECT DISTINCT group_row.id groupId,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
@@ -1,20 +0,0 @@
|
||||
package com.baiyue.firstleg.loading.coload.persistence;
|
||||
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 平台同柜组摘要的只读数据访问,保留原入仓片段和组状态范围。 */
|
||||
@Repository
|
||||
public class ColoadGroupQueryRepository {
|
||||
private final ColoadGroupMapper mapper;
|
||||
/** 注入同服务持久化入口。 */
|
||||
public ColoadGroupQueryRepository(ColoadGroupMapper mapper) { this.mapper = mapper; }
|
||||
/** 按业务入仓码查询当前组及其真实业务组号。 */
|
||||
public List<ColoadPersistenceRows.InboundGroupLookupRow> groupsByInboundCode(String code) {
|
||||
return mapper.groupsByInboundCode(code);
|
||||
}
|
||||
/** 读取被冻结的成员,交由服务核对缺失和重复片段。 */
|
||||
public List<ColoadPersistenceRows.MemberCodeRow> effectiveMemberCodes(long groupId) {
|
||||
return mapper.effectiveMemberCodes(groupId);
|
||||
}
|
||||
}
|
||||
-1
@@ -59,7 +59,6 @@ public final class ColoadPersistenceRows {
|
||||
@Setter
|
||||
public static class InboundGroupLookupRow {
|
||||
private Long groupId;
|
||||
private String groupNo;
|
||||
private String groupStatus;
|
||||
}
|
||||
|
||||
|
||||
+3
-6
@@ -47,8 +47,6 @@ 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;
|
||||
@@ -120,11 +118,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
return requiredSnapshot(groupId);
|
||||
}
|
||||
|
||||
/** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */
|
||||
@Transactional(readOnly = true)
|
||||
public BusinessSummary queryByInboundCode(String inboundCode) {
|
||||
String code = normalizedInboundCode(inboundCode);
|
||||
var groups = queryRepository.groupsByInboundCode(code).stream()
|
||||
var groups = mapper.groupsByInboundCode(code).stream()
|
||||
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
|
||||
.toList();
|
||||
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
|
||||
@@ -134,7 +131,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 = queryRepository.effectiveMemberCodes(groupId);
|
||||
var memberRows = mapper.effectiveMemberCodes(groupId);
|
||||
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
|
||||
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|
||||
|| trim(row.getInboundCode()) == null)) {
|
||||
@@ -145,7 +142,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
throw conflict("同柜不可拆组存在重复入仓片段业务号");
|
||||
}
|
||||
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
|
||||
return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members);
|
||||
return new BusinessSummary(status, members.size(), members);
|
||||
}
|
||||
|
||||
private long requiredCurrentGroupId(String inboundCode) {
|
||||
|
||||
+1
-6
@@ -63,7 +63,6 @@ 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,
|
||||
@@ -71,7 +70,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(arrival.actual_at) AS arrivedDestinationPortAt,
|
||||
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
||||
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
||||
@@ -82,10 +81,6 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
|
||||
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
|
||||
|
||||
@@ -24,7 +24,7 @@ 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();
|
||||
@@ -32,24 +32,19 @@ public class WorkflowTabService {
|
||||
if(!sources.isEmpty()) {
|
||||
try {
|
||||
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
||||
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()));
|
||||
if(response==null || !response.successful() || response.data()==null) throw unavailable();
|
||||
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("尾程事实缺少来源 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);
|
||||
}
|
||||
if(overseas.size()!=sources.size()) throw unavailable();
|
||||
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
|
||||
}
|
||||
// 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("头程事实与入仓单归属不一致 inboundId="+fact.inboundId());
|
||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
|
||||
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
|
||||
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
|
||||
}
|
||||
@@ -82,9 +77,5 @@ public class WorkflowTabService {
|
||||
return keys;
|
||||
}
|
||||
/** 下游缺失或归属异常不能生成部分数量。 */
|
||||
private static BizException unavailable(String reason) {
|
||||
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
|
||||
return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试")
|
||||
.initCause(new IllegalStateException(reason));
|
||||
}
|
||||
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ paths:
|
||||
summary: 按入仓码查询同柜组业务摘要
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
|
||||
返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
||||
页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。
|
||||
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
||||
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。
|
||||
parameters:
|
||||
- name: inboundCode
|
||||
in: path
|
||||
@@ -147,9 +147,8 @@ components:
|
||||
ColoadGroupBusinessSummary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [groupNo, status, memberCount, members]
|
||||
required: [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
-1
@@ -55,7 +55,7 @@ class ColoadGroupOpenApiContractTest {
|
||||
.doesNotContain("destinationCode")
|
||||
.doesNotContain("expectedVersion")
|
||||
.doesNotContain("platform:firstleg:coload");
|
||||
assertThat(summaryProperties).containsOnlyKeys("groupNo", "status", "memberCount", "members");
|
||||
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members");
|
||||
assertThat(memberProperties).containsOnlyKeys("inboundCode");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -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, com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository.class})
|
||||
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class})
|
||||
class ColoadGroupServiceTest {
|
||||
@org.springframework.context.annotation.Configuration
|
||||
static class Numbers {
|
||||
@@ -71,7 +71,6 @@ 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");
|
||||
@@ -575,7 +574,6 @@ 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;
|
||||
}
|
||||
|
||||
+1
-2
@@ -86,8 +86,7 @@ class ColoadGroupSqlContractTest {
|
||||
.contains("requested.inbound_code=#{inboundCode}")
|
||||
.contains("requested.deleted=0")
|
||||
.contains("'ACTIVE','LOCKED','CONSUMED'")
|
||||
.contains("group_row.group_no groupNo")
|
||||
.doesNotContain("order_id=#{");
|
||||
.doesNotContain("group_no", "order_id=#{");
|
||||
assertThat(memberSql)
|
||||
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
|
||||
.contains("LEFT JOIN fl_inbound_order inbound")
|
||||
|
||||
+3
-7
@@ -44,9 +44,8 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留批量读取,实际到港与日历采用相同的最新观测顺序。 */
|
||||
@Test
|
||||
void batchesActualContainerFactsWithLatestObservedArrivalAndWithoutEstimatedTimes() throws Exception {
|
||||
void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception {
|
||||
String sql = String.join("\n", CarrierScheduleQueryMapper.class
|
||||
.getMethod("selectContainerActualFacts", List.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
@@ -56,16 +55,13 @@ 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(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='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(5);
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3);
|
||||
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
-46
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -11,51 +10,6 @@ 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());
|
||||
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,15 +25,12 @@ 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)
|
||||
.hasMessage("订单业务页签事实暂不可用,请稍后重试")
|
||||
.hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]");
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
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);
|
||||
var failure=new IllegalStateException("unavailable");
|
||||
when(downstream.read(any(),any())).thenThrow(failure);
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
||||
|
||||
@@ -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,9 +56,8 @@
|
||||
| 贴标申请、箱唛/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`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
|
||||
| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` |
|
||||
@@ -78,7 +77,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,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
|
||||
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
||||
| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实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` |
|
||||
|
||||
@@ -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`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
||||
- 自有业务运营:`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-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` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
|
||||
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`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` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
|
||||
|
||||
@@ -28,10 +28,6 @@
|
||||
|
||||
`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,10 +4,6 @@
|
||||
<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>
|
||||
@@ -46,7 +42,6 @@
|
||||
<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,18 +72,11 @@ 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.'));
|
||||
|
||||
-5
@@ -25,11 +25,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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,6 +37,25 @@ 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")
|
||||
|
||||
+3
-48
@@ -126,51 +126,6 @@ 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();
|
||||
@@ -214,9 +169,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(commandKey);arrangement.setOriginalOperatorId(actor.operatorId());
|
||||
arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId());
|
||||
arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement);
|
||||
return fact(arrangement);
|
||||
return execution.recordArrangement(context,actor,identity,fact(arrangement),false);
|
||||
}
|
||||
|
||||
/** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */
|
||||
@@ -326,7 +281,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(input.plannedDeliveryAt()==null?null:local(input.plannedDeliveryAt()));
|
||||
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(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());
|
||||
|
||||
+10
-10
@@ -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.DispatchLoadingDepartureService loadingDeparture;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure;
|
||||
|
||||
/** 注入本域应用服务。 */
|
||||
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(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 派送异常登记不触发签收或退货入库。 */
|
||||
@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));
|
||||
/** 与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)
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
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
@@ -1,60 +0,0 @@
|
||||
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,7 +36,6 @@ 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 + "')")
|
||||
@@ -48,14 +47,6 @@ 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(required=false) String warehouseId,
|
||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam 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,8 +9,6 @@ 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;
|
||||
@@ -26,7 +24,6 @@ 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, DRIVER_PHOTO, EXCEPTION }
|
||||
enum Kind { VEHICLE, DRIVER }
|
||||
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
|
||||
/** 上传只写本车、本类证据。 */
|
||||
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
|
||||
|
||||
+2
-6
@@ -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 || !supportedType(kind,file.getContentType()))throw invalid();
|
||||
if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid();
|
||||
var body=new MultipartBodyBuilder();
|
||||
try { body.part("file",new ByteArrayResource(file.getBytes()) {
|
||||
/** 保留用户文件名供审阅,不把路径当业务事实。 */
|
||||
@@ -80,13 +80,9 @@ 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()
|
||||
|| !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
|
||||
|| !TYPES.contains(Objects.toString(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明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */
|
||||
|
||||
+7
-22
@@ -26,9 +26,6 @@ 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>";
|
||||
@@ -40,12 +37,12 @@ public interface DispatchAggregateMapper {
|
||||
|
||||
/** 首次创建整车所有权及初始版本,不写customer_id。 */
|
||||
@Insert("""
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,publish_looseboard_demand,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,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.publishLooseboardDemand},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
||||
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{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},
|
||||
@@ -66,17 +63,6 @@ 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);
|
||||
@@ -90,26 +76,25 @@ 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=#{dispatchReview},loading_review_json=#{loadingReview},
|
||||
review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending},
|
||||
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("dispatchReview") String dispatchReview, @Param("loadingReview") String loadingReview,
|
||||
@Param("at") java.time.LocalDateTime at);
|
||||
@Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at);
|
||||
|
||||
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
|
||||
@Update("""
|
||||
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},publish_looseboard_demand=#{r.publishLooseboardDemand},
|
||||
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
|
||||
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
|
||||
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
|
||||
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},
|
||||
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},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount},
|
||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||
WHERE id=#{r.id} AND record_version=#{version}
|
||||
""")
|
||||
|
||||
+1
-2
@@ -50,8 +50,7 @@ public interface DispatchCandidatePageMapper {
|
||||
}
|
||||
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
|
||||
private static String filtered(boolean count) {
|
||||
return " FROM ("+rows(count)+") candidates WHERE 1=1"
|
||||
+"<if test='query.warehouseId != null'> AND overseasWarehouseId=#{query.warehouseId}</if>"
|
||||
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
|
||||
+"<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,11 +93,6 @@ 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);
|
||||
|
||||
+11
-10
@@ -20,7 +20,7 @@ public class DispatchPdaController {
|
||||
@Resource private DispatchPdaQueryService queries;
|
||||
@Resource private DispatchPickingService picking;
|
||||
@Resource private DispatchAggregateService workflow;
|
||||
@Resource private DispatchLoadingDepartureService loadingDeparture;
|
||||
@Resource private DispatchDepartureService departure;
|
||||
|
||||
/** 批量读取当前绑定仓的整车作业。 */
|
||||
@GetMapping
|
||||
@@ -36,13 +36,6 @@ 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,
|
||||
@@ -57,10 +50,18 @@ 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(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,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));
|
||||
}
|
||||
|
||||
/** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.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;
|
||||
|
||||
/** 绑定仓PDA装车资料入口,与另一端共用五框和自动派送门禁。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
|
||||
public class PdaDispatchLoadingDocumentController {
|
||||
private final DispatchLoadingDocumentService service;
|
||||
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
|
||||
public PdaDispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
|
||||
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.get(DispatchActorFactory.pda(user),dispatchNo));
|
||||
}
|
||||
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
|
||||
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
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.pda(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
|
||||
@PutMapping("/{slot}")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@PathVariable Slot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(DispatchActorFactory.pda(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 原上传仅允许当前有权限的原操作者恢复。 */
|
||||
@GetMapping("/uploads/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.recover(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
|
||||
@GetMapping("/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) {
|
||||
var value=service.content(DispatchActorFactory.pda(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());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public interface DispatchWholePdaReadMapper {
|
||||
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */
|
||||
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
|
||||
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
|
||||
+"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
|
||||
+"<otherwise>AND stage IN('LOADING','AFTER_LOADING','DELIVERING') </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
|
||||
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
|
||||
String ROOTS="<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
|
||||
/** 一次读取选中整车所有真实客户货物。 */
|
||||
|
||||
@@ -24,6 +24,9 @@ public final class DispatchApi {
|
||||
public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review";
|
||||
public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review";
|
||||
public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete";
|
||||
public static final String DEPART = "/{dispatchNo}/depart";
|
||||
public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement";
|
||||
public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery";
|
||||
|
||||
/** 端点容器不允许实例化。 */
|
||||
private DispatchApi() { }
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** 内部余位池仅公开本车运输摘要,不包含客户货物或司机私人资料。 */
|
||||
public final class DispatchCapacityDemands {
|
||||
/** 只承载只读查询模型。 */
|
||||
private DispatchCapacityDemands() { }
|
||||
public record Query(Integer page, Integer size, String origin, String destination) {
|
||||
/** 查询范围有界,默认每页二十条。 */
|
||||
public Query {
|
||||
page = page == null ? 1 : page; size = size == null ? 20 : size;
|
||||
if (page < 1 || size < 1 || size > 100) throw new IllegalArgumentException("余位需求分页参数无效");
|
||||
origin = origin == null || origin.isBlank() ? null : origin.trim();
|
||||
destination = destination == null || destination.isBlank() ? null : destination.trim();
|
||||
}
|
||||
}
|
||||
public record Demand(String dispatchNo, String warehouseName, String route, Instant plannedDepartureAt,
|
||||
Integer vehiclePalletSpaces, Integer scheduledPalletCount, Integer remainingPalletSpaces,
|
||||
BigDecimal remainingWeightKg, BigDecimal remainingVolumeCbm, boolean capacityKnown, String capacityReason) { }
|
||||
}
|
||||
+8
-18
@@ -23,16 +23,13 @@ public final class DispatchCommands {
|
||||
/** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */
|
||||
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
|
||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
|
||||
/** 新建排车默认发布内部余位,显式取消时保持不发布。 */
|
||||
public Create { publishLooseboardDemand = publishLooseboardDemand == null ? true : publishLooseboardDemand; }
|
||||
}
|
||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations) { }
|
||||
|
||||
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt,
|
||||
String remark, Boolean publishLooseboardDemand) {
|
||||
String remark) {
|
||||
/** 缺版本与有效版本零必须区分。 */
|
||||
public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); }
|
||||
public Update { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
public record AddCargo(Long recordVersion, String idempotencyKey, List<CargoSelection> cargo,
|
||||
@@ -52,9 +49,9 @@ public final class DispatchCommands {
|
||||
}
|
||||
|
||||
public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision,
|
||||
String reason, ReviewAttachments attachments) {
|
||||
/** 两级审核都执行相同的必填版本约束,未传附件表示没有本次补充。 */
|
||||
public ReviewCommand { recordVersion = requireVersion(recordVersion); attachments = attachments == null ? ReviewAttachments.empty() : attachments; }
|
||||
String reason) {
|
||||
/** 两级审核都执行相同的必填版本约束。 */
|
||||
public ReviewCommand { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
/** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */
|
||||
@@ -69,20 +66,13 @@ public final class DispatchCommands {
|
||||
public Pick { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
/** 按服务端已核平的扫码托盘确认装车,不要求现场重复测量或补填箱数。 */
|
||||
/** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */
|
||||
public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash,
|
||||
String remark) {
|
||||
List<LoadingMeasurement> measurements, String remark) {
|
||||
/** 后台与PDA共用必填版本约束。 */
|
||||
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
|
||||
public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
|
||||
ReturnDirection returnDirection, String description, List<Long> imageFileIds) {
|
||||
/** 必填版本用于阻止过期页面覆盖当前排车。 */
|
||||
public MarkException { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
public record Depart(Long recordVersion, String idempotencyKey, String remark) {
|
||||
/** 实际发车不得接受缺省版本。 */
|
||||
public Depart { recordVersion = requireVersion(recordVersion); }
|
||||
|
||||
+2
-30
@@ -25,15 +25,7 @@ public final class DispatchContracts {
|
||||
|
||||
/** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */
|
||||
public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn,
|
||||
List<Long> fileIds, Instant expectedTaskEndAt, List<Long> photoFileIds,
|
||||
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) Long masterLicenseFileId) {
|
||||
/** 普通派送继续按既有主档证照语义构造,取货显式记录主档引用来源。 */
|
||||
public DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, List<Long> fileIds, Instant expectedTaskEndAt, List<Long> photoFileIds) {
|
||||
this(licenseNo, licenseExpiresOn, fileIds, expectedTaskEndAt, photoFileIds, null);
|
||||
}
|
||||
/** 司机照片独立于驾驶证,缺省为空且引用须有界、有效、不重复。 */
|
||||
public DriverEvidence { photoFileIds = ReviewAttachments.checked(photoFileIds); }
|
||||
}
|
||||
List<Long> fileIds, Instant expectedTaskEndAt) { }
|
||||
|
||||
public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate,
|
||||
String carrierName, String vehicleStatus, Capacity capacity,
|
||||
@@ -49,27 +41,7 @@ public final class DispatchContracts {
|
||||
String contactName, String contactPhone) { }
|
||||
|
||||
public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt,
|
||||
long reviewRound, String basisHash, String invalidatedReason, ReviewAttachments attachments) {
|
||||
/** 无补充附件时返回明确空集合,旧决定不得从资源快照自动继承。 */
|
||||
public Review { attachments = attachments == null ? ReviewAttachments.empty() : attachments; }
|
||||
}
|
||||
|
||||
/** 本次审核补充证据按分类归属本车,不修改车辆或司机业务资料。 */
|
||||
public record ReviewAttachments(List<Long> vehicleFileIds, List<Long> driverPhotoFileIds, List<Long> licenseFileIds) {
|
||||
/** 每类附件有界、不可变且不允许空身份或重复引用。 */
|
||||
public ReviewAttachments {
|
||||
vehicleFileIds = checked(vehicleFileIds); driverPhotoFileIds = checked(driverPhotoFileIds); licenseFileIds = checked(licenseFileIds);
|
||||
}
|
||||
/** 未传附件表示本次没有补充证据。 */
|
||||
public static ReviewAttachments empty() { return new ReviewAttachments(List.of(), List.of(), List.of()); }
|
||||
/** 只规范引用形状;本车和类别归属由审核服务逐个验证。 */
|
||||
private static List<Long> checked(List<Long> ids) {
|
||||
if (ids == null) return List.of();
|
||||
if (ids.size() > 20 || ids.stream().anyMatch(id -> id == null || id <= 0) || ids.stream().distinct().count() != ids.size())
|
||||
throw new IllegalArgumentException("每类审核附件最多20个,文件引用须有效且不重复");
|
||||
return List.copyOf(ids);
|
||||
}
|
||||
}
|
||||
long reviewRound, String basisHash, String invalidatedReason) { }
|
||||
|
||||
public record Reviews(Review dispatchReview, Review loadingReview) { }
|
||||
|
||||
|
||||
-3
@@ -7,9 +7,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
|
||||
/** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */
|
||||
public interface DispatchDeliveryPort {
|
||||
/** 装车完成父事务自动准备全车真实客户单据,不要求人工安排或补造费用。 */
|
||||
ArrangementFact prepareForDeparture(Context context, Actor actor, String commandKey);
|
||||
|
||||
/** 一次预览返回同快照下全部客户目的地及费用证据。 */
|
||||
ArrangementPreview preview(Context context);
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ public final class DispatchTypes {
|
||||
public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED }
|
||||
public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED }
|
||||
public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED }
|
||||
public enum ExceptionType { REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION }
|
||||
public enum ReturnDirection { OVERSEAS_WAREHOUSE, OTHER }
|
||||
public enum Decision { APPROVED, REJECTED }
|
||||
public enum ResourceMode { MASTER, EXTERNAL }
|
||||
public enum ActorMode { PLATFORM, PDA }
|
||||
@@ -20,6 +18,6 @@ public final class DispatchTypes {
|
||||
public enum Effect { APPLIED, EXISTING }
|
||||
public enum Action {
|
||||
CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW,
|
||||
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART, MARK_EXCEPTION
|
||||
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,7 @@ public final class DispatchViews {
|
||||
|
||||
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
|
||||
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
|
||||
List<BatchReference> appointmentBatches, List<ExceptionRecord> exceptions) { }
|
||||
|
||||
/** 排车级异常记录;退货去向只描述安排,不产生退货入库事实。 */
|
||||
public record ExceptionRecord(ExceptionType exceptionType, ReturnDirection returnDirection,
|
||||
String description, List<Long> imageFileIds, Instant occurredAt) { }
|
||||
List<BatchReference> appointmentBatches) { }
|
||||
|
||||
public record Documents(String dispatchNo, List<DispatchDeliveryModels.OutboundReference> outbounds,
|
||||
List<DispatchDeliveryModels.DeliveryReference> deliveries,
|
||||
@@ -64,12 +60,11 @@ public final class DispatchViews {
|
||||
ReviewState reviewState, Reviews reviews, Counts counts,
|
||||
boolean pickingReconciled, Map<Action, Gate> actions,
|
||||
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
|
||||
List<OrderDetail> orderDetails, boolean publishLooseboardDemand) { }
|
||||
List<OrderDetail> orderDetails) { }
|
||||
|
||||
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
|
||||
Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts,
|
||||
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments) { }
|
||||
Stage stage, long recordVersion, Reviews reviews, Counts counts,
|
||||
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions) { }
|
||||
|
||||
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
|
||||
Destination destination, long scheduledPackages, long pickedPackages,
|
||||
|
||||
+2
-4
@@ -34,8 +34,6 @@ public class DispatchAggregateRepository {
|
||||
public DispatchAggregate lock(String number) { return mapper.lock(number); }
|
||||
/** 整车分页。 */
|
||||
public List<DispatchAggregate> page(DispatchAggregateMapper.Search query) { return mapper.page(query); }
|
||||
/** 一次读取当前有效的内部余位来源,服务计算满载筛选后再分页。 */
|
||||
public List<DispatchAggregate> capacityDemands(com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) { return mapper.capacityDemands(query); }
|
||||
/** 整车分页总数。 */
|
||||
public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); }
|
||||
/** 全搜索基集的阶段和审核分布。 */
|
||||
@@ -48,8 +46,8 @@ public class DispatchAggregateRepository {
|
||||
public int removeItem(long itemId) {return items.deleteById(itemId);}
|
||||
/** 原子更新货量并作废旧审核依据。 */
|
||||
public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages,
|
||||
String route, String dispatchReview, String loadingReview, LocalDateTime at) {
|
||||
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, dispatchReview, loadingReview, at);
|
||||
String route, String pending, LocalDateTime at) {
|
||||
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at);
|
||||
}
|
||||
/** 追加不可变业务快照。 */
|
||||
public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) {
|
||||
|
||||
-2
@@ -74,8 +74,6 @@ public class DispatchSourceRepository {
|
||||
public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); }
|
||||
/** 锁定真实计划托盘根。 */
|
||||
public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); }
|
||||
/** 只读核对原计划已经结束实际收货。 */
|
||||
public boolean receivedPlanCompleted(String customer,Long id){return source.receivedPlanCompleted(customer,id)==1;}
|
||||
/** 锁定实际退回收据根。 */
|
||||
public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); }
|
||||
/** 真实库存数量及版本。 */
|
||||
|
||||
+44
-196
@@ -51,7 +51,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
|
||||
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks;
|
||||
|
||||
/** 领域编排只访问Repository和提供者应用入口。 */
|
||||
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
|
||||
@@ -147,8 +146,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchPlanningModels.CandidateList candidates(Actor actor,DispatchPlanningModels.CandidateQuery query) {
|
||||
requirePlatform(actor);
|
||||
// 未指定起运仓时按区域浏览;写入草稿仍由来源确定唯一真实起运仓。
|
||||
if(query==null || query.warehouseId()!=null && !query.warehouseId().matches("[1-9][0-9]*")) throw bad("起运仓筛选无效");
|
||||
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
|
||||
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
|
||||
if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法");
|
||||
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
|
||||
@@ -237,18 +235,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return saveCommand(row,actor,identity,request.reason());
|
||||
}
|
||||
|
||||
/** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */
|
||||
/** 上传不修改主单引用;返回本车文件ID后由公共版本编辑命令保存选择。 */
|
||||
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number,
|
||||
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);
|
||||
if(kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION) {
|
||||
requireExceptionAllowed(actor,row,row.getRecordVersion());
|
||||
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
|
||||
}
|
||||
requireEditing(row,row.getRecordVersion());
|
||||
Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW
|
||||
: ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT;
|
||||
if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限");
|
||||
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
|
||||
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
|
||||
}
|
||||
/** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */
|
||||
@@ -274,8 +264,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
|
||||
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|
||||
|| !driver.evidence().fileIds().contains(fileId))return null;
|
||||
// 取货允许补传本车驾驶证,仅明确冻结的主档引用才走司机归属。
|
||||
if("PICKUP".equals(row.getTaskType()) && !Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
|
||||
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
|
||||
return driver.driverId();
|
||||
}
|
||||
@@ -305,12 +293,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
row.setId(id); row.setDispatchNo(number); row.setTripId(shell.tripId());
|
||||
row.setOverseasWarehouseId(shell.warehouseId()); row.setOverseasWarehouseCode(shell.warehouseCode()); row.setOverseasWarehouseName(shell.warehouseName());
|
||||
row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L);
|
||||
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
|
||||
row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L);
|
||||
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
|
||||
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
||||
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
Resources resources=resources(shell,photos); row.setResourcesJson(write(resources));
|
||||
Resources resources=resources(shell); row.setResourcesJson(write(resources));
|
||||
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
|
||||
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
|
||||
@@ -388,13 +373,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
String before=basis(row);
|
||||
// 本趟司机照片随资源维护保存,逐个核对本车及照片类别。
|
||||
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
||||
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
|
||||
row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
|
||||
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
|
||||
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
|
||||
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
|
||||
return saveCommand(row,actor,identity,request.remark());
|
||||
@@ -408,10 +389,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
requireEditing(row,request.recordVersion());
|
||||
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||
verifyReviewResources(row);
|
||||
if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核");
|
||||
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
|
||||
long round=row.getReviewRound()+1;String basis=basis(row);
|
||||
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
|
||||
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null)));
|
||||
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null)));
|
||||
row.setLoadingReviewJson(write(pending(round)));
|
||||
return saveCommand(row,actor,identity,request.remark());
|
||||
}
|
||||
@@ -419,14 +400,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) {
|
||||
Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
|
||||
// PDA仅可处理本绑定仓理货审核,原键重试也先重新核验权限和范围。
|
||||
DispatchAggregate row;
|
||||
if(actor!=null && actor.mode()==ActorMode.PDA) {
|
||||
if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action))
|
||||
throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核");
|
||||
lockReviewRoot(actor,number);row=required(actor,number,true);
|
||||
} else row=lockPlatform(actor,number);
|
||||
var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
|
||||
var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
@@ -434,26 +408,19 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name();
|
||||
if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化");
|
||||
if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空");
|
||||
if(request.decision()==Decision.APPROVED && !Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
|
||||
if(loading && request.decision()==Decision.APPROVED) {
|
||||
if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
|
||||
if(loading) {
|
||||
var dispatch=reviews(row).dispatchReview();
|
||||
if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效");
|
||||
}
|
||||
verifyReviewAttachments(row.getId(),request.attachments());
|
||||
if(request.decision()==Decision.APPROVED)verifyReviewResources(row);
|
||||
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments());
|
||||
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null);
|
||||
if(loading) {
|
||||
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
|
||||
if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name());
|
||||
} else {
|
||||
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
|
||||
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound())));
|
||||
}
|
||||
if(request.decision()==Decision.REJECTED) {
|
||||
// 任一级驳回均回客服处理,旧调度批准不再作为可继续理货的有效依据。
|
||||
row.setStage(Stage.BEFORE_LOADING.name());row.setReviewBasisHash(null);
|
||||
if(loading)row.setDispatchReviewJson(write(new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,
|
||||
row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核",reviews(row).dispatchReview().attachments())));
|
||||
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound())));
|
||||
}
|
||||
return saveCommand(row,actor,identity,request.reason());
|
||||
}
|
||||
@@ -537,10 +504,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true));
|
||||
if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true));
|
||||
}
|
||||
var absent=new ArrayList<String>();if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
|
||||
var absent=new ArrayList<String>();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
|
||||
missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList());
|
||||
pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(),
|
||||
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),1,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
|
||||
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
|
||||
}
|
||||
if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源");
|
||||
String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets));
|
||||
@@ -550,7 +517,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing));
|
||||
}
|
||||
|
||||
/** 实载确认:扫码核平后保存装载证据并进入装车后,五张装车资料齐全再推进派送。 */
|
||||
/** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true);
|
||||
@@ -562,15 +529,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
|
||||
lockExecutionSources(row,items);
|
||||
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
|
||||
String appointmentBlock=appointments.departureBlockReason(number);
|
||||
if(appointmentBlock!=null)throw conflict(appointmentBlock);
|
||||
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
||||
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
|
||||
// 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。
|
||||
var measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,
|
||||
p.weightKg(),p.volumeCbm(),p.deliveryIndex())).sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
|
||||
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
|
||||
var supplied=new HashMap<Long,LoadingMeasurement>();
|
||||
for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复");
|
||||
for(var p:preview.pallets()) {
|
||||
var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex()
|
||||
|| p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对");
|
||||
}
|
||||
verifyReviewResources(row);var context=context(actor,row);
|
||||
var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList();
|
||||
var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
|
||||
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
|
||||
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
|
||||
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
|
||||
@@ -618,12 +587,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
}
|
||||
|
||||
/** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */
|
||||
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); }
|
||||
/** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */
|
||||
private void lockReviewRoot(Actor actor,String number) {
|
||||
var row=required(actor,number,false);
|
||||
if("PICKUP".equals(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number);
|
||||
}
|
||||
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); }
|
||||
/** 严格请求身份不接收前端摘要或操作者。 */
|
||||
private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));}
|
||||
/** 每次状态变更、历史及不可变命令结果同事务保存。 */
|
||||
@@ -640,36 +604,16 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */
|
||||
private String basis(DispatchAggregate row) {
|
||||
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
|
||||
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
|
||||
return hashes.source(facts);
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts);
|
||||
}
|
||||
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
|
||||
private void invalidate(DispatchAggregate row,String reason) {
|
||||
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
|
||||
var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason,null);
|
||||
var previous=reviews(row);
|
||||
row.setDispatchReviewJson(write(retainRejection(previous.dispatchReview(),invalid)));
|
||||
row.setLoadingReviewJson(write(retainRejection(previous.loadingReview(),invalid)));
|
||||
var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason);
|
||||
row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid));
|
||||
}
|
||||
/** 客服改资料期间保留本轮驳回原因;下一轮提交统一重置,不复用批准。 */
|
||||
private static Review retainRejection(Review previous,Review invalid) {
|
||||
return previous.status()==ReviewStatus.REJECTED
|
||||
?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason(),previous.attachments())
|
||||
:new Review(invalid.status(),null,null,null,invalid.reviewRound(),null,invalid.invalidatedReason(),previous.attachments());
|
||||
}
|
||||
/** 审核附件逐个验证本车与种类;MASTER档案证照不能冒充本次上传证据。 */
|
||||
private void verifyReviewAttachments(long dispatch,ReviewAttachments attachments) {
|
||||
for(var id:attachments.vehicleFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,id);
|
||||
for(var id:attachments.driverPhotoFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,id);
|
||||
for(var id:attachments.licenseFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
|
||||
}
|
||||
|
||||
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
|
||||
void verifyReviewResources(DispatchAggregate row) {
|
||||
if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;}
|
||||
requireReviewAppointment(row);
|
||||
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
|
||||
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
|
||||
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
|
||||
@@ -677,21 +621,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
|
||||
if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
|
||||
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
|
||||
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
|
||||
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
|
||||
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
}
|
||||
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
|
||||
void requireReviewAppointment(DispatchAggregate row) {
|
||||
String reason=reviewAppointmentBlock(row);
|
||||
if(reason!=null)throw conflict(reason);
|
||||
}
|
||||
private String reviewAppointmentBlock(DispatchAggregate row) {
|
||||
String reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
|
||||
if(reason==null && row.getReviewBasisHash()!=null && !canSubmitReview(row.getReviewState()) && !Objects.equals(row.getReviewBasisHash(),basis(row)))
|
||||
return "预约或运输安排已变化,原审核依据失效,请重新核对审核";
|
||||
return reason;
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
}
|
||||
/** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */
|
||||
private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) {
|
||||
@@ -699,40 +631,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
for(var id:ids)evidence.metadata(dispatch,kind,id);
|
||||
}
|
||||
|
||||
/** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) {
|
||||
var row=lockPlatform(actor,number);
|
||||
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
|
||||
var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
requireExceptionAllowed(actor,row,request.recordVersion());
|
||||
if(request.exceptionType()==null)throw bad("请选择异常类型");
|
||||
var description=trim(request.description());
|
||||
if(description!=null && description.length()>1000)throw bad("异常说明不能超过1000字");
|
||||
var ids=request.imageFileIds()==null?List.<Long>of():request.imageFileIds();
|
||||
if(ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("异常图片引用无效或超过20张");
|
||||
// 文件须属于本车异常类别,不能借用其他车辆或审核附件。
|
||||
for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id);
|
||||
var records=new ArrayList<>(exceptionRecords(row));
|
||||
records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now()));
|
||||
row.setExceptionsJson(write(records));
|
||||
return saveCommand(row,actor,identity,description);
|
||||
}
|
||||
|
||||
/** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */
|
||||
private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) {
|
||||
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
|
||||
if(row.getRecordVersion()!=version)throw conflict("排车版本已变化,请刷新后再标记异常");
|
||||
if(!actions(actor,row).get(Action.MARK_EXCEPTION).allowed())throw conflict("仅派送中的仓库派送排车可以标记异常");
|
||||
}
|
||||
|
||||
/** 尚未登记时为空集合,已登记记录按保存顺序完整回看。 */
|
||||
private List<DispatchViews.ExceptionRecord> exceptionRecords(DispatchAggregate row) {
|
||||
if(row.getExceptionsJson()==null)return List.of();
|
||||
return Arrays.asList(read(row.getExceptionsJson(),DispatchViews.ExceptionRecord[].class));
|
||||
}
|
||||
|
||||
/** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchViews.Page page(Actor actor, DispatchViews.Query query) {
|
||||
@@ -750,18 +648,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
statistics(repository.summaries(filter)),Instant.now());
|
||||
}
|
||||
|
||||
/** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */
|
||||
List<DispatchViews.FbaAppointment> reviewAppointments(DispatchAggregate row) {
|
||||
String number=row.getDispatchNo();var batches=repository.batches(row.getId());
|
||||
var confirmedAppointments=this.appointments.reviewEvidence(number);
|
||||
return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
|
||||
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
|
||||
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
|
||||
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
|
||||
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchViews.Detail detail(Actor actor, String number) {
|
||||
@@ -772,15 +658,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b));
|
||||
var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(),
|
||||
group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList();
|
||||
var appointments=reviewAppointments(row);
|
||||
var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
|
||||
b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream()
|
||||
.map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList();
|
||||
var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(),
|
||||
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
|
||||
d.status(),d.packageCount(),d.palletCount())).toList();
|
||||
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
|
||||
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
||||
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
|
||||
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
|
||||
documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand()));
|
||||
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs),
|
||||
documents,orderDetails(cargo));
|
||||
}
|
||||
|
||||
/** 恢复动作结果前重新核验当前单据范围和原动作权限。 */
|
||||
@@ -838,8 +726,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count();
|
||||
String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(","));
|
||||
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
|
||||
invalidate(row,"排车货物已修改,请客服重新提交审核");
|
||||
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,row.getDispatchReviewJson(),row.getLoadingReviewJson(),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
|
||||
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
|
||||
var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
|
||||
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops);
|
||||
@@ -888,28 +775,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
|
||||
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
|
||||
var blocked=new EnumMap<Action,Gate>(Action.class);
|
||||
boolean pickup="PICKUP".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus());
|
||||
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) {
|
||||
boolean state=pickup && switch(action) {
|
||||
case EDIT,CANCEL -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage());
|
||||
case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState());
|
||||
case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState());
|
||||
case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState());
|
||||
default -> false;
|
||||
};
|
||||
boolean allowed=state && hasPermission(actor,action);
|
||||
blocked.put(action,new Gate(allowed,allowed?null:"TRANSPORT_TASK",allowed?null:"请按取货运输审核流程操作"));
|
||||
}
|
||||
for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
|
||||
return Collections.unmodifiableMap(blocked);
|
||||
}
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
|
||||
String reviewBlock=active && (stage==Stage.BEFORE_LOADING || stage==Stage.LOADING)
|
||||
?reviewAppointmentBlock(row):null;
|
||||
for(var action:Action.values()) {
|
||||
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
|
||||
boolean state=switch(action) {
|
||||
case MARK_EXCEPTION -> active && stage==Stage.DELIVERING;
|
||||
case CREATE -> actor.mode()==ActorMode.PLATFORM;
|
||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
||||
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
|
||||
@@ -923,32 +795,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
};
|
||||
boolean permitted=hasPermission(actor,action);
|
||||
gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件"));
|
||||
if(state && permitted && reviewBlock!=null && Set.of(Action.SUBMIT_REVIEW,Action.DISPATCH_REVIEW,Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE).contains(action)) {
|
||||
boolean rejectionAllowed=action==Action.DISPATCH_REVIEW || action==Action.LOADING_REVIEW;
|
||||
gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock));
|
||||
}
|
||||
if(action==Action.LOADING_COMPLETE && state && permitted) {
|
||||
String reason=appointments.departureBlockReason(row.getDispatchNo());
|
||||
if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason));
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableMap(gates);
|
||||
}
|
||||
|
||||
/** 装车发运及原结果恢复都按当前装车权限与PDA绑定身份校验。 */
|
||||
void requireLoadingPermission(Actor actor) {
|
||||
if(actor==null || actor.operatorId()==null || !hasPermission(actor,Action.LOADING_COMPLETE))
|
||||
throw new BizException(ResultCode.FORBIDDEN,"当前无装车完成权限");
|
||||
}
|
||||
|
||||
/** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */
|
||||
private static boolean hasPermission(Actor actor,Action action) {
|
||||
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)return false;
|
||||
var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false;
|
||||
if(actor.mode()==ActorMode.PDA) return Set.of(Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
|
||||
&& actor.boundWarehouseId()!=null
|
||||
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()))
|
||||
&& auth.getAuthorities().stream().anyMatch(a->"ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR".equals(a.getAuthority()));
|
||||
if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
|
||||
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()));
|
||||
if(action==Action.PICK) return false;
|
||||
String permission=switch(action) {
|
||||
case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel";
|
||||
@@ -959,9 +814,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
/** PDA审核读取整车冻结资源,与后台共用相同快照,查询不更改主档。 */
|
||||
Resources resources(DispatchAggregate row) { return read(row.getResourcesJson(),Resources.class); }
|
||||
|
||||
/** 统计按真整车去重并补齐全部零项。 */
|
||||
private static DispatchViews.Statistics statistics(List<DispatchAggregateMapper.Summary> groups) {
|
||||
var stages=new EnumMap<Stage,Long>(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L);
|
||||
@@ -969,13 +821,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
for(var group:groups) {
|
||||
if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; }
|
||||
active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum);
|
||||
if(stage==Stage.BEFORE_LOADING) {
|
||||
// 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。
|
||||
var review=ReviewState.valueOf(group.reviewState());
|
||||
if(canSubmitReview(review.name()))before+=group.quantity();
|
||||
else if(review==ReviewState.DISPATCH_PENDING)dispatch[1]+=group.quantity();
|
||||
else if(review==ReviewState.LOADING_PENDING)loading[1]+=group.quantity();
|
||||
}
|
||||
if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState());
|
||||
dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity();
|
||||
loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); }
|
||||
}
|
||||
return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before,
|
||||
new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3])));
|
||||
@@ -1000,12 +848,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 合并Fleet权威资源与本趟司机照片,照片不写入司机主档。 */
|
||||
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos) {
|
||||
/** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */
|
||||
private static Resources resources(FleetExecutionModels.DispatchShell shell) {
|
||||
var v=shell.vehicle(); var d=shell.driver();
|
||||
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
|
||||
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)));
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt())));
|
||||
}
|
||||
|
||||
/** 主档选择不接受请求覆盖其车牌和容量。 */
|
||||
@@ -1035,7 +883,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 当前审核记录,不以版本相等代替轮次有效性。 */
|
||||
Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); }
|
||||
/** 未提交的审核不伪造待审核。 */
|
||||
private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null,null); }
|
||||
private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); }
|
||||
/** 整车真实统计。 */
|
||||
private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); }
|
||||
/** 空单永远不能核平为装车完成。 */
|
||||
|
||||
-6
@@ -17,12 +17,6 @@ public class DispatchAppointmentAllocations {
|
||||
private final AppointmentWorkDispatchPort provider;
|
||||
public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;}
|
||||
|
||||
/** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */
|
||||
public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);}
|
||||
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
|
||||
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
|
||||
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
|
||||
|
||||
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
|
||||
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
|
||||
var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList();
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 从日常排车实时投影内部余位,不创建第二套车次、发布单或占位。 */
|
||||
@Service
|
||||
public class DispatchCapacityDemandService {
|
||||
private final DispatchAggregateRepository repository;
|
||||
private final ObjectMapper json;
|
||||
/** 复用排车仓储和正式装载快照解析。 */
|
||||
public DispatchCapacityDemandService(DispatchAggregateRepository repository, ObjectMapper json) {
|
||||
this.repository = repository; this.json = json;
|
||||
}
|
||||
/** 当前候选一次查询;已知满载先过滤,随后分页,保证总数与结果一致。 */
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public PageResult<DispatchCapacityDemands.Demand> page(DispatchCapacityDemands.Query query) {
|
||||
var values = repository.capacityDemands(query).stream().map(this::project).filter(Objects::nonNull).toList();
|
||||
long start = Math.min((long)(query.page()-1)*query.size(), values.size());
|
||||
int end = (int)Math.min(start+query.size(), values.size());
|
||||
return new PageResult<>(values.subList((int)start,end), query.page(), query.size(), values.size(), (values.size()+query.size()-1)/query.size());
|
||||
}
|
||||
/** 实托数不等于标准托位;缺少可靠装载证据时明确未知,不能拿三十托或零值推算。 */
|
||||
private DispatchCapacityDemands.Demand project(DispatchAggregate row) {
|
||||
Integer slots = null, remaining = null; BigDecimal weight = null, volume = null;
|
||||
String reason = "占用标准托位、重量及体积待确认";
|
||||
try {
|
||||
var resources = row.getResourcesJson() == null ? null : json.readValue(row.getResourcesJson(), DispatchContracts.Resources.class);
|
||||
var cap = resources == null || resources.vehicle() == null ? null : resources.vehicle().capacity();
|
||||
if (cap != null) slots = cap.palletSpaces();
|
||||
var load = row.getLoadingConfirmationJson() == null ? null : json.readValue(row.getLoadingConfirmationJson(), DispatchContracts.ConfirmedLoad.class);
|
||||
if (cap == null || slots == null || slots <= 0 || !positive(cap.maxLoadKg()) || !positive(cap.maxVolumeCbm())) reason = "车辆真实容量待确认";
|
||||
if (cap != null && load != null && load.pallets() != null && !load.pallets().isEmpty()
|
||||
&& load.pallets().stream().allMatch(p -> p.warehousePalletId()!=null && p.palletSpaces()>0)
|
||||
&& load.pallets().stream().map(DispatchContracts.LoadingMeasurement::warehousePalletId).distinct().count()==load.pallets().size()) {
|
||||
// 首版均在起运仓上车,后续仅卸货;任一已知维度满载都退出需求池。
|
||||
long occupied = load.pallets().stream().mapToLong(DispatchContracts.LoadingMeasurement::palletSpaces).sum();
|
||||
if (slots != null && slots > 0) { if (occupied >= slots) return null; remaining = Math.toIntExact(slots-occupied); }
|
||||
if (positive(cap.maxLoadKg()) && load.pallets().stream().allMatch(p -> positive(p.weightKg()))) {
|
||||
var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::weightKg).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
if (used.compareTo(cap.maxLoadKg())>=0) return null; weight=cap.maxLoadKg().subtract(used);
|
||||
}
|
||||
if (positive(cap.maxVolumeCbm()) && load.pallets().stream().allMatch(p -> positive(p.volumeCbm()))) {
|
||||
var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::volumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
if (used.compareTo(cap.maxVolumeCbm())>=0) return null; volume=cap.maxVolumeCbm().subtract(used);
|
||||
}
|
||||
if (remaining!=null && weight!=null && volume!=null) reason=null;
|
||||
}
|
||||
} catch (JsonProcessingException invalid) { reason="车辆或装载资料待核对"; }
|
||||
return new DispatchCapacityDemands.Demand(row.getDispatchNo(),row.getOverseasWarehouseName(),row.getRoute(),
|
||||
row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(ZoneOffset.UTC),slots,row.getPalletCount(),remaining,weight,volume,reason==null,reason);
|
||||
}
|
||||
/** 三维容量均须有真实正值。 */
|
||||
private static boolean positive(BigDecimal value) { return value!=null && value.signum()>0; }
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,7 @@ public final class DispatchCommandCodec {
|
||||
case DEPART -> DispatchResults.Departed.class;
|
||||
case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class;
|
||||
case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW,
|
||||
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW, MARK_EXCEPTION -> DispatchResults.Saved.class;
|
||||
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-7
@@ -19,7 +19,7 @@ import java.util.TreeMap;
|
||||
@Component
|
||||
public final class DispatchCommandHasher {
|
||||
private static final Set<String> UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services",
|
||||
"batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds", "photoFileIds");
|
||||
"batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds");
|
||||
private final ObjectMapper json;
|
||||
|
||||
/** 复用已注册的时间、数值和公开字段序列化配置。 */
|
||||
@@ -60,12 +60,7 @@ public final class DispatchCommandHasher {
|
||||
if (node == null || node.isNull()) return "null";
|
||||
if (node.isObject()) {
|
||||
var values = new TreeMap<String, String>();
|
||||
node.fields().forEachRemaining(entry -> {
|
||||
// 新增可选照片为空时沿用旧摘要,保留历史命令重放与已通过审核的依据。
|
||||
if (entry.getKey().equals("photoFileIds") && (entry.getValue().isNull()
|
||||
|| entry.getValue().isArray() && entry.getValue().isEmpty())) return;
|
||||
values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()));
|
||||
});
|
||||
node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey())));
|
||||
var result = new ArrayList<String>();
|
||||
values.forEach((key, value) -> result.add(quoted(key) + ":" + value));
|
||||
return "{" + String.join(",", result) + "}";
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 装车与资料可并行完成,两项齐全时在当前事务自动推进派送。 */
|
||||
@Service
|
||||
public class DispatchLoadingDepartureService {
|
||||
private final DispatchAggregateService workflow;
|
||||
private final DispatchDeliveryPort delivery;
|
||||
private final DispatchDepartureService departure;
|
||||
private final DispatchCommandLedger commands;
|
||||
private final DispatchCommandHasher hashes;
|
||||
private final DispatchLoadingPhotoPort documents;
|
||||
|
||||
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
|
||||
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery,
|
||||
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes,
|
||||
DispatchLoadingPhotoPort documents) {
|
||||
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
|
||||
this.documents=documents;
|
||||
}
|
||||
|
||||
/** 原请求记录最终阶段;重放不重复装车、创建派送或发车。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
workflow.requireLoadingPermission(actor);
|
||||
var context=workflow.lockForArrangement(actor,number);
|
||||
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
|
||||
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
|
||||
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
|
||||
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.remark()));
|
||||
var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
|
||||
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
|
||||
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
|
||||
finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false);
|
||||
commands.append(context.dispatchId(),actor,identity,result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult departAfterDocuments(Actor actor,String number,String key) {
|
||||
var context=workflow.lockForArrangement(actor,number);
|
||||
if(context.stage()!=Stage.AFTER_LOADING || context.status()!=Status.ACTIVE)
|
||||
throw new BizException(ResultCode.CONFLICT,"当前阶段不能按装车资料推进派送");
|
||||
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
|
||||
delivery.departureFacts(context,true);
|
||||
String arrangementKey=stepKey(number,key,"ARRANGE");
|
||||
var fact=delivery.prepareForDeparture(context,actor,arrangementKey);
|
||||
var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(),
|
||||
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
|
||||
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
|
||||
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(),
|
||||
stepKey(number,key,"DEPART"),"五张装车照片已齐全,自动进入派送中"));
|
||||
if(departed.stage()!=Stage.DELIVERING)
|
||||
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
|
||||
return departed;
|
||||
}
|
||||
|
||||
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
|
||||
private String stepKey(String number,String original,String step) {
|
||||
return "AUTO_"+step+"_"+hashes.source(List.of(number,original,step));
|
||||
}
|
||||
}
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.util.*;
|
||||
|
||||
/** Admin/PDA按整车共用装车资料;通过车队公开端口保存照片,不读取其持久化。 */
|
||||
@Service
|
||||
public class DispatchLoadingDocumentService {
|
||||
public static final List<Slot> SLOTS=List.of(Slot.values());
|
||||
private final DispatchAggregateService workflow;
|
||||
private final DispatchLoadingDepartureService departure;
|
||||
private final DispatchLoadingPhotoPort photos;
|
||||
/** 装车资料与履约原子编排留在排车域,车队只提供照片事实。 */
|
||||
public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure,DispatchLoadingPhotoPort photos) {
|
||||
this.workflow=workflow;this.departure=departure;this.photos=photos;
|
||||
}
|
||||
/** GET只读,PDA每次按可信主体重验冻结仓。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Documents get(Actor actor,String number) {
|
||||
require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true));
|
||||
}
|
||||
/** 上传先记原请求意图,不提前绑定或推进派送。 */
|
||||
public Photo upload(Actor actor,String number,Slot slot,String key,MultipartFile file) {
|
||||
require(actor,true);var context=context(actor,number,false);return photos.upload(context.dispatchId(),actor.operatorId(),slot,key,file);
|
||||
}
|
||||
/** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */
|
||||
public Photo recover(Actor actor,String number,String key) {
|
||||
require(actor,true);var context=context(actor,number,false);return photos.recover(context.dispatchId(),actor.operatorId(),key);
|
||||
}
|
||||
/** 锁顺序为车次、排车、附加行;第五张绑定与派送发车共同提交或回滚。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) {
|
||||
require(actor,true);var context=context(actor,number,true);
|
||||
var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay;
|
||||
var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,request);
|
||||
if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) {
|
||||
departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number);
|
||||
}
|
||||
var result=documents(context,state,true);
|
||||
photos.recordBind(context.dispatchId(),actor.operatorId(),slot,request,result);return result;
|
||||
}
|
||||
/** 当前引用由照片提供者再次核验,拒绝未绑定或其他车文件。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Download content(Actor actor,String number,Slot slot,long fileId) {
|
||||
require(actor,false);var context=context(actor,number,false);return photos.content(context.dispatchId(),slot,fileId);
|
||||
}
|
||||
/** 装车中可同步上传;后续阶段仍须有真实装载证据,仓隔离沿用主流程。 */
|
||||
private Context context(Actor actor,String number,boolean lock) {
|
||||
var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number);
|
||||
if(value.status()!=Status.ACTIVE || !Set.of(Stage.LOADING,Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage())
|
||||
|| value.stage()!=Stage.LOADING && (value.loadingCompletedAt()==null || value.loadingCompletionHash()==null))
|
||||
throw new BizException(ResultCode.CONFLICT,"进入装车中后才能管理装车资料");
|
||||
return value;
|
||||
}
|
||||
/** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */
|
||||
private Documents documents(Context context,State state,boolean editable) {
|
||||
return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),context.stage().name(),editable,complete(state.photos()),state.photos());
|
||||
}
|
||||
/** 按五个固定框核齐,不能用总数或重复槽位代替。 */
|
||||
static boolean complete(List<Photo> photos) {
|
||||
return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank()));
|
||||
}
|
||||
/** 平台list与edit分离,PDA使用既有作业角色和绑定仓。 */
|
||||
private void require(Actor actor,boolean write) {
|
||||
if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限");
|
||||
}
|
||||
/** 权限取当前会话,不为PDA补造平台权限。 */
|
||||
private boolean allowed(Actor actor,boolean write) {
|
||||
var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false;
|
||||
var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList();
|
||||
return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate")
|
||||
&& authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")
|
||||
:authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list"));
|
||||
}
|
||||
}
|
||||
+32
-9
@@ -87,21 +87,44 @@ public class DispatchPdaQueryService {
|
||||
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
|
||||
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
|
||||
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
|
||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions),
|
||||
root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),
|
||||
root.getEstimatedArrivalAt()==null?null:root.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC),workflow.reviewAppointments(root)));
|
||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
|
||||
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
|
||||
var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
|
||||
if(received.isEmpty())return List.of();
|
||||
// 只修改本次投影对象,不在GET更新排车、版本或库存。
|
||||
item.setPackageCount(received.get().source().packageCount());
|
||||
return received.get().positions();
|
||||
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return List.of();
|
||||
var actual=rows.get(0);
|
||||
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|
||||
|| !Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|
||||
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|
||||
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
|
||||
|| !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|
||||
|| !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of();
|
||||
var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of();
|
||||
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
|
||||
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
|
||||
|| n(selected.getPackageCount())!=member.packages())return List.of();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId())
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()))
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of();
|
||||
return stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList();
|
||||
}
|
||||
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||
|
||||
+21
-34
@@ -27,7 +27,6 @@ public class DispatchPickingService {
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet;
|
||||
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
|
||||
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
|
||||
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
|
||||
@@ -47,7 +46,6 @@ public class DispatchPickingService {
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING
|
||||
|| context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码");
|
||||
workflow.requireReviewAppointment(row);
|
||||
if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法");
|
||||
try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");}
|
||||
var currentItems=repository.cargo(row.getId());
|
||||
@@ -103,44 +101,33 @@ public class DispatchPickingService {
|
||||
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
|
||||
commands.append(row.getId(),actor,identity,result);return result;
|
||||
}
|
||||
/** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
|
||||
/** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */
|
||||
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
|
||||
long dispatch=row.getId();
|
||||
if(item.getWarehousePalletId()!=null) {
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
|
||||
}
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
|
||||
for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
|
||||
.ifPresent(fact->received.put(candidate,fact));
|
||||
if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
|
||||
// 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。
|
||||
for(var entry:received.entrySet()) {
|
||||
entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
|
||||
entry.getKey().setPackageCount(entry.getValue().source().packageCount());
|
||||
}
|
||||
if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托");
|
||||
var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(orders.isEmpty())throw conflict("原计划缺少真实订单组成");
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0);
|
||||
if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对");
|
||||
if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单");
|
||||
item.setWarehousePalletId(actual.warehousePalletId());
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
|
||||
for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
|
||||
|| !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
|
||||
}
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var candidate=entry.getKey();var actual=entry.getValue().source();
|
||||
try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");}
|
||||
candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存");
|
||||
}
|
||||
row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对");
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true);
|
||||
var pallets=new ArrayList<Long>();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId());
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId());
|
||||
try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");}
|
||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存");
|
||||
}
|
||||
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
|
||||
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
|
||||
|
||||
/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */
|
||||
final class DispatchReceivedPlan {
|
||||
record Fact(DispatchPalletCandidate source, List<DispatchViews.Position> positions) { }
|
||||
|
||||
/** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */
|
||||
static Optional<Fact> resolve(String warehouse, DispatchItem item, List<DispatchItem> items,
|
||||
DispatchSourceRepository sources, DispatchService rules) {
|
||||
if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null
|
||||
|| !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty();
|
||||
if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return Optional.empty();
|
||||
var actual=rows.get(0);
|
||||
if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty();
|
||||
var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(original.isEmpty() || !new HashSet<>(original).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty();
|
||||
var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty();
|
||||
// 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。
|
||||
if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null)
|
||||
&& (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse)
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0
|
||||
|| p.allocatedPackages()!=0 || p.pickedPackages()!=0)
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty();
|
||||
return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList()));
|
||||
}
|
||||
|
||||
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
|
||||
static boolean sameIdentity(DispatchItem item,DispatchPalletCandidate actual) {
|
||||
return "RECEIVED".equals(actual.sourceStage()) && actual.warehousePalletId()!=null
|
||||
&& Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|
||||
&& Objects.equals(actual.customerId(),item.getCustomerId())
|
||||
&& Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
&& Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
&& Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|
||||
&& Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress());
|
||||
}
|
||||
}
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.*;
|
||||
|
||||
/** 取货审核照片按本车分类校验,驾驶证主档引用明确区分于本车补传。 */
|
||||
@Service
|
||||
public class TransportReviewPhotos {
|
||||
private final DispatchEvidenceGateway files;
|
||||
private final DriverService drivers;
|
||||
private final ObjectMapper json;
|
||||
/** 注入正式文件归属与司机资格入口。 */
|
||||
public TransportReviewPhotos(DispatchEvidenceGateway files, DriverService drivers, ObjectMapper json) {
|
||||
this.files=files; this.drivers=drivers; this.json=json;
|
||||
}
|
||||
/** 读取已保存资源;空排车还没有照片。 */
|
||||
public Resources resources(DispatchAggregate row) {
|
||||
if(row.getResourcesJson()==null)return new Resources(null,null);
|
||||
try{return json.readValue(row.getResourcesJson(),Resources.class);}
|
||||
catch(Exception e){throw new IllegalStateException("取货照片快照读取失败",e);}
|
||||
}
|
||||
/** 校验三类照片及主档身份,换车或换司机不能沿用旧对象的照片。 */
|
||||
public DriverEvidence validate(DispatchAggregate row, FleetTripEntity trip, TransportTaskModels.Assign request) {
|
||||
var photos=request.reviewPhotos();
|
||||
if(photos==null || photos.vehicleFileIds().isEmpty() || photos.driverPhotoFileIds().isEmpty()
|
||||
|| photos.licenseFileIds().isEmpty() && request.masterLicenseFileId()==null)
|
||||
throw conflict("请补齐车辆图片、司机照片和驾驶证照片后保存");
|
||||
var old=resources(row);
|
||||
if(old.vehicle()!=null && !Objects.equals(old.vehicle().vehicleId(),request.vehicleId()))
|
||||
rejectReused(old.vehicle().evidenceFileIds(),photos.vehicleFileIds());
|
||||
if(old.driver()!=null && old.driver().evidence()!=null && !Objects.equals(old.driver().driverId(),request.driverId())) {
|
||||
rejectReused(old.driver().evidence().photoFileIds(),photos.driverPhotoFileIds());
|
||||
rejectReused(old.driver().evidence().fileIds(),photos.licenseFileIds());
|
||||
}
|
||||
var driver=drivers.requireDispatchable(request.driverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
|
||||
if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机");
|
||||
verify(row.getId(),VEHICLE,photos.vehicleFileIds());
|
||||
verify(row.getId(),DRIVER_PHOTO,photos.driverPhotoFileIds());
|
||||
verify(row.getId(),DRIVER,photos.licenseFileIds());
|
||||
var licenseIds=new ArrayList<>(photos.licenseFileIds());
|
||||
if(request.masterLicenseFileId()!=null) {
|
||||
if(!request.masterLicenseFileId().toString().equals(driver.licenseFileId()))throw conflict("司机主档驾驶证已变化,请重新选择司机");
|
||||
drivers.download(request.driverId(),request.masterLicenseFileId());
|
||||
if(licenseIds.contains(request.masterLicenseFileId()))throw conflict("驾驶证主档引用不能作为本车上传重复提交");
|
||||
licenseIds.add(request.masterLicenseFileId());
|
||||
}
|
||||
return new DriverEvidence(driver.details().licenseNumber(),driver.details().licenseExpiresOn(),List.copyOf(licenseIds),
|
||||
trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC),photos.driverPhotoFileIds(),request.masterLicenseFileId());
|
||||
}
|
||||
/** 提交和两级通过均复验照片,避免仅前端必填而绕过审核依据。 */
|
||||
public void verifyReview(DispatchAggregate row, FleetTripEntity trip) {
|
||||
var saved=resources(row);var driver=saved.driver();var vehicle=saved.vehicle();
|
||||
if(vehicle==null || driver==null || driver.evidence()==null
|
||||
|| !Objects.equals(vehicle.vehicleId(),trip.getVehicleId()) || !Objects.equals(driver.driverId(),trip.getDriverId()))
|
||||
throw conflict("请维护车辆司机并补齐审核照片");
|
||||
var e=driver.evidence();
|
||||
var licenses=e.fileIds()==null?List.<Long>of():e.fileIds().stream().filter(id->!Objects.equals(id,e.masterLicenseFileId())).toList();
|
||||
validate(row,trip,new TransportTaskModels.Assign(trip.getRecordVersion(),trip.getVehicleId(),trip.getDriverId(),"核验取货照片",
|
||||
row.getRecordVersion(),new ReviewAttachments(vehicle.evidenceFileIds(),e.photoFileIds(),licenses),e.masterLicenseFileId()));
|
||||
}
|
||||
/** 不把同排车旧司机或车辆的上传当成新对象的证据。 */
|
||||
private void rejectReused(List<Long> old,List<Long> selected) {
|
||||
if(old!=null && selected.stream().anyMatch(old::contains))throw conflict("车辆或司机已更换,请重新上传对应照片");
|
||||
}
|
||||
/** 文件网关逐项校验平台、本车和文件类别,失效或跨单引用直接失败。 */
|
||||
private void verify(long dispatch,DispatchEvidenceGateway.Kind kind,List<Long> ids) {
|
||||
for(long id:ids)files.metadata(dispatch,kind,id);
|
||||
}
|
||||
/** 返回可处理的业务冲突。 */
|
||||
private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
+1
-1
@@ -39,7 +39,7 @@ public class TransportTaskController {
|
||||
public ApiResponse<?> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));}
|
||||
/** 分配或改派真实车辆司机。 */
|
||||
@PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
|
||||
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
|
||||
/** 取消未发车任务,来源回到待安排。 */
|
||||
@PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')")
|
||||
public ApiResponse<?> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));}
|
||||
|
||||
+1
-7
@@ -18,12 +18,6 @@ public final class TransportTaskModels {
|
||||
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
|
||||
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
|
||||
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
|
||||
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
|
||||
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { }
|
||||
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
|
||||
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
|
||||
@Min(0) Long dispatchRecordVersion,
|
||||
@Valid com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments reviewPhotos,
|
||||
@Positive Long masterLicenseFileId) { }
|
||||
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence) { }
|
||||
public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { }
|
||||
}
|
||||
|
||||
+6
-65
@@ -18,13 +18,12 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */
|
||||
@Service
|
||||
public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.PickupDepartureReview {
|
||||
public class TransportTaskProjection {
|
||||
private final DispatchAggregateRepository roots;
|
||||
private final TransportTaskRepository links;
|
||||
private final UsTruckOrderRepository trucks;
|
||||
private final DispatchNumberGenerator numbers;
|
||||
private final ObjectMapper json;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickupFacts;
|
||||
/** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */
|
||||
public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) {
|
||||
this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json;
|
||||
@@ -37,7 +36,7 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
|
||||
row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName());
|
||||
row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L);
|
||||
row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L);
|
||||
var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null,null);
|
||||
var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null);
|
||||
row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review));
|
||||
resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());
|
||||
row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages);
|
||||
@@ -51,22 +50,11 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
|
||||
var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return;
|
||||
var found=links.byTrip(trip.getId());if(found==null)return;
|
||||
var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion();
|
||||
row.setRecordVersion(version+1);row.setUpdatedAt(trip.getUpdatedAt());
|
||||
row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt());
|
||||
row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE");
|
||||
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";case "PLANNED"->row.getStage();default->"BEFORE_LOADING";});
|
||||
// 旧发车入口只记录出发事实,取货照片尚未齐全时仍在装货中。
|
||||
if("PICKUP".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus())
|
||||
&& pickupFacts.tasks(trip.getTripNo()).stream().anyMatch(task->"ASSIGNED".equals(task.getStatus())))row.setStage("LOADING");
|
||||
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";});
|
||||
row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt());
|
||||
String oldResources=row.getResourcesJson();var oldDeparture=row.getPlannedDepartureAt();var oldArrival=row.getEstimatedArrivalAt();
|
||||
row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip);
|
||||
// 资源或计划改变后作废本轮审核,保留审核驳回原因供修改时核对。
|
||||
if("PICKUP".equals(row.getTaskType()) && "PLANNED".equals(trip.getStatus())
|
||||
&& (!Objects.equals(oldResources,row.getResourcesJson()) || !Objects.equals(oldDeparture,row.getPlannedDepartureAt()) || !Objects.equals(oldArrival,row.getEstimatedArrivalAt()))) {
|
||||
row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
|
||||
row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound()));
|
||||
row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound()));
|
||||
}
|
||||
if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败");
|
||||
roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt());
|
||||
var link=links.truck(row.getDispatchNo());if(link==null)return;
|
||||
@@ -74,30 +62,6 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
|
||||
transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion());
|
||||
if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo());
|
||||
}
|
||||
/** 旧取货发车入口也必须锁定并核验同轮两级批准,不允许无主单发车。 */
|
||||
@Override
|
||||
public void requireApproved(long tripId) {
|
||||
var found=links.byTrip(tripId);
|
||||
if(found==null)throw new IllegalStateException("取货排车缺失,请先接入排车审核");
|
||||
var row=roots.lock(found.getDispatchNo());
|
||||
if(!"PICKUP".equals(row.getTaskType()) || !"ACTIVE".equals(row.getStatus()) || !"APPROVED".equals(row.getReviewState()) || row.getReviewBasisHash()==null)
|
||||
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"请先完成调度审核和理货员审核");
|
||||
for(String value:List.of(row.getDispatchReviewJson(),row.getLoadingReviewJson())) {
|
||||
try {
|
||||
var review=json.readValue(value,Review.class);
|
||||
if(review.status()!=ReviewStatus.APPROVED || review.reviewRound()!=row.getReviewRound() || !Objects.equals(review.basisHash(),row.getReviewBasisHash()))
|
||||
throw new IllegalStateException("取货审核依据已失效,请重新提交审核");
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
|
||||
}
|
||||
}
|
||||
/** 保留驳回事实,同时明确此前批准已因资源变更失效。 */
|
||||
private String invalidated(String value,long round) {
|
||||
try {
|
||||
var old=json.readValue(value,Review.class);
|
||||
return write(new Review(old.status()==ReviewStatus.REJECTED?ReviewStatus.REJECTED:ReviewStatus.NOT_SUBMITTED,
|
||||
old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机、照片或计划已变更,请重新提交两级审核",old.attachments()));
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
|
||||
}
|
||||
/** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */
|
||||
public void transition(String customer,String number,String target,String action,String operator,String reason,String key) {
|
||||
var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在");
|
||||
@@ -111,32 +75,9 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
|
||||
}
|
||||
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */
|
||||
private void resources(DispatchAggregate row,FleetTripEntity trip) {
|
||||
var old=readResources(row);
|
||||
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
|
||||
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),
|
||||
old.vehicle()!=null && Objects.equals(old.vehicle().vehicleId(),trip.getVehicleId())?old.vehicle().evidenceFileIds():List.of()),
|
||||
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),
|
||||
old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null))));
|
||||
}
|
||||
/** 照片变更单独纳入主单版本和审核依据,资源分配失败时由外层事务一起回滚。 */
|
||||
public void savePhotos(String number,String operator,List<Long> vehiclePhotos,DriverEvidence driverPhotos) {
|
||||
var row=roots.lock(number);var old=readResources(row);var v=old.vehicle();var d=old.driver();
|
||||
if(v==null || d==null)throw new IllegalStateException("请先完成车辆司机分配");
|
||||
var next=new Resources(new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),vehiclePhotos),
|
||||
new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos));
|
||||
if(next.equals(old))return;
|
||||
long version=row.getRecordVersion();row.setResourcesJson(write(next));row.setRecordVersion(version+1);
|
||||
row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
|
||||
row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound()));
|
||||
row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound()));
|
||||
if(roots.saveState(row,version)!=1)throw new IllegalStateException("取货照片保存版本冲突");
|
||||
roots.history(IdWorker.getId(),row,"PICKUP_RESOURCE_PHOTOS",operator,"保存车辆、司机和驾驶证审核照片",write(row),row.getUpdatedAt());
|
||||
}
|
||||
/** 状态投影只保留同一资源的已保存照片,不能在运输流转中清空审核证据。 */
|
||||
private Resources readResources(DispatchAggregate row) {
|
||||
if(row.getResourcesJson()==null)return new Resources(null,null);
|
||||
try{return json.readValue(row.getResourcesJson(),Resources.class);}
|
||||
catch(Exception e){throw new IllegalStateException("运输资源照片读取失败",e);}
|
||||
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()),
|
||||
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null))));
|
||||
}
|
||||
/** 保存明确的业务快照,失败时终止事务。 */
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}}
|
||||
|
||||
+4
-42
@@ -35,7 +35,6 @@ public class TransportTaskService {
|
||||
private final FleetTransportVehicleGuard vehicles;
|
||||
private final TripOperationsEvidenceGateway files;
|
||||
private final ObjectMapper json;
|
||||
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
|
||||
/** 复用已有资格、占用、原单及凭证能力。 */
|
||||
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
|
||||
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
|
||||
@@ -52,22 +51,6 @@ public class TransportTaskService {
|
||||
var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator);
|
||||
return detail(row.getDispatchNo());
|
||||
}
|
||||
/** 审核通过后按原单锁幂等生成待发车排车;重复审核保留原安排及取消结果。 */
|
||||
@Transactional
|
||||
public void ensureApprovedPickup(String customer,String number,String operator) {
|
||||
var source=pickupFacts.source(customer,number,true);
|
||||
if(source==null)throw missing("转运单不存在");
|
||||
if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !number.equals(source.getInboundCode())
|
||||
|| !"WAIT_PDA_RECEIVING".equals(source.getStatus()))return;
|
||||
var task=pickupFacts.lockTask(source.getId());
|
||||
// 已安排、执行或主动取消的取货任务均不因审核重放重新建单。
|
||||
if(task!=null)return;
|
||||
var departure=Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
createPickup(operator,new FleetPickupModels.Create(List.of(new FleetPickupModels.Source(customer,number)),
|
||||
departure,departure.plus(Duration.ofHours(24)),
|
||||
"审核自动生成到仓取货排车;时间为暂定计划,调度确认后分配车辆司机",
|
||||
"transfer-approval:"+source.getId()));
|
||||
}
|
||||
/** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) {
|
||||
@@ -96,8 +79,7 @@ public class TransportTaskService {
|
||||
var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number);
|
||||
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
|
||||
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
|
||||
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList(),row.getRecordVersion(),
|
||||
"PICKUP".equals(row.getTaskType())?reviewPhotos.resources(row):null);
|
||||
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList());
|
||||
}
|
||||
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
|
||||
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
|
||||
@@ -109,32 +91,12 @@ public class TransportTaskService {
|
||||
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务");
|
||||
String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip;
|
||||
}
|
||||
/** 资源与三类照片同事务保存;双版本阻止审核期间的旧页面覆盖。 */
|
||||
/** 分配实际资源,容量超限时整次分配回滚。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail assign(String number,String operator,TransportTaskModels.Assign request) {
|
||||
var trip=transport(number,true);
|
||||
com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence photos=null;
|
||||
if("PICKUP".equals(trip.getTripPurpose())) {
|
||||
var row=roots.lock(number);
|
||||
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|
||||
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
|
||||
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
|
||||
photos=reviewPhotos.validate(row,trip,request);
|
||||
}
|
||||
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
|
||||
if(photos!=null)projection.savePhotos(number,operator,request.reviewPhotos().vehicleFileIds(),photos);
|
||||
public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) {
|
||||
var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request);
|
||||
if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number);
|
||||
}
|
||||
/** 取货提交与两级通过时复验真实占用、有效证照和车辆容量。 */
|
||||
public void verifyPickupReview(String number) {
|
||||
var trip=transport(number,true);
|
||||
if(!"PICKUP".equals(trip.getTripPurpose()) || !"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).isEmpty())throw conflict("仅有真实取货任务的未发车排车可以审核");
|
||||
if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null || !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间");
|
||||
var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm();
|
||||
requireResources(trip);
|
||||
reviewPhotos.verifyReview(roots.find(number),trip);
|
||||
if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息");
|
||||
}
|
||||
/** 取消运输并保留原单、排车及资源审计。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) {
|
||||
|
||||
@@ -7,6 +7,5 @@ public record ContainerArrivalTimeView(
|
||||
String sourceContainerBookingId,
|
||||
LocalDateTime estimatedWarehouseArrivalAt,
|
||||
LocalDateTime actualWarehouseArrivalAt,
|
||||
int version,
|
||||
long planCount
|
||||
int version
|
||||
) { }
|
||||
|
||||
+9
-26
@@ -12,14 +12,14 @@ public final class TripCapacityPolicy {
|
||||
|
||||
public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) {
|
||||
public Load {
|
||||
if (palletSpaces < 0 || weightKg != null && weightKg.signum() < 0 || volumeCbm != null && volumeCbm.signum() < 0) {
|
||||
if (palletSpaces < 0 || weightKg == null || volumeCbm == null
|
||||
|| weightKg.signum() < 0 || volumeCbm.signum() < 0) {
|
||||
throw new IllegalArgumentException("托位、重量和体积须为非负数");
|
||||
}
|
||||
}
|
||||
public Load plus(Load other) {
|
||||
return new Load(Math.addExact(palletSpaces, other.palletSpaces),
|
||||
weightKg == null || other.weightKg == null ? null : weightKg.add(other.weightKg),
|
||||
volumeCbm == null || other.volumeCbm == null ? null : volumeCbm.add(other.volumeCbm));
|
||||
weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public final class TripCapacityPolicy {
|
||||
if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号");
|
||||
if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后");
|
||||
Objects.requireNonNull(load, "load");
|
||||
if (load.palletSpaces() == 0 || load.weightKg() != null && load.weightKg().signum() == 0 || load.volumeCbm() != null && load.volumeCbm().signum() == 0) {
|
||||
if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) {
|
||||
throw new IllegalArgumentException("承运货物须有有效托位、重量和体积");
|
||||
}
|
||||
}
|
||||
@@ -37,48 +37,31 @@ public final class TripCapacityPolicy {
|
||||
public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { }
|
||||
|
||||
public static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings) {
|
||||
return calculate(stopCount, capacity, bookings, false);
|
||||
}
|
||||
|
||||
/** 装车使用已有测量;未知维度保留空值,仅校验已知货量,不把未知当作可售余量。 */
|
||||
public static List<Segment> calculateActual(int stopCount, Load capacity, List<Booking> bookings) {
|
||||
return calculate(stopCount, capacity, bookings, true);
|
||||
}
|
||||
|
||||
private static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings, boolean actual) {
|
||||
Objects.requireNonNull(capacity, "capacity");
|
||||
Objects.requireNonNull(bookings, "bookings");
|
||||
if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点");
|
||||
if (capacity.weightKg() == null || capacity.volumeCbm() == null || capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
|
||||
if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
|
||||
throw new IllegalArgumentException("车辆容量必须大于零");
|
||||
}
|
||||
var numbers = new HashSet<String>();
|
||||
for (Booking booking : bookings) {
|
||||
Objects.requireNonNull(booking, "booking");
|
||||
if (!actual && (booking.load().weightKg() == null || booking.load().volumeCbm() == null))
|
||||
throw new IllegalArgumentException("计划承运货物须有重量和体积");
|
||||
if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配");
|
||||
if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线");
|
||||
}
|
||||
List<Segment> segments = new ArrayList<>();
|
||||
for (int index = 0; index < stopCount - 1; index++) {
|
||||
Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
BigDecimal knownWeight = BigDecimal.ZERO, knownVolume = BigDecimal.ZERO;
|
||||
for (Booking booking : bookings) {
|
||||
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) {
|
||||
occupied = occupied.plus(booking.load());
|
||||
if (booking.load().weightKg() != null) knownWeight = knownWeight.add(booking.load().weightKg());
|
||||
if (booking.load().volumeCbm() != null) knownVolume = knownVolume.add(booking.load().volumeCbm());
|
||||
}
|
||||
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load());
|
||||
}
|
||||
if (occupied.palletSpaces() > capacity.palletSpaces()
|
||||
|| knownWeight.compareTo(capacity.weightKg()) > 0
|
||||
|| knownVolume.compareTo(capacity.volumeCbm()) > 0) {
|
||||
|| occupied.weightKg().compareTo(capacity.weightKg()) > 0
|
||||
|| occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) {
|
||||
throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制");
|
||||
}
|
||||
Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(),
|
||||
occupied.weightKg() == null ? null : capacity.weightKg().subtract(occupied.weightKg()),
|
||||
occupied.volumeCbm() == null ? null : capacity.volumeCbm().subtract(occupied.volumeCbm()));
|
||||
capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm()));
|
||||
segments.add(new Segment(index, index + 1, occupied, remaining));
|
||||
}
|
||||
return List.copyOf(segments);
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ public final class FleetHandoverPolicy {
|
||||
return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey());
|
||||
}
|
||||
private static boolean positive(Load load) {
|
||||
return load != null && load.palletSpaces() > 0 && load.weightKg() != null && load.volumeCbm() != null && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
|
||||
return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
|
||||
}
|
||||
private static boolean blank(String text) { return text == null || text.isBlank(); }
|
||||
private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); }
|
||||
|
||||
+4
-4
@@ -37,7 +37,7 @@ public class OutsourcedBusinessService {
|
||||
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
|
||||
var result=queries.history(actor,number,page,size,SCOPE);
|
||||
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
|
||||
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().filter(p->"LOADING".equals(p.category())).map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
|
||||
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
|
||||
}
|
||||
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
|
||||
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
|
||||
@@ -71,11 +71,11 @@ public class OutsourcedBusinessService {
|
||||
}
|
||||
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
|
||||
private Detail detail(OwnedBusinessModels.Detail detail) {
|
||||
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().filter(p->"LOADING".equals(p.category())).map(this::photo).toList());
|
||||
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
|
||||
}
|
||||
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
|
||||
private Row row(OwnedBusinessModels.Row r,String carrierName) {
|
||||
if(!java.util.Set.of("OWNED","DISPATCHED").contains(r.vehicle().ownership()))throw conflict("外配运营快照的车辆归属或照片不合法");
|
||||
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
|
||||
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
|
||||
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
|
||||
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
|
||||
@@ -85,7 +85,7 @@ public class OutsourcedBusinessService {
|
||||
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
|
||||
return new Operations(ops.money(),ops.arrivals());
|
||||
}
|
||||
/** 外配照片枚举只有五框,排车油耗图片保留在共用记录中但不在外配入口输出。 */
|
||||
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
|
||||
private Photo photo(OwnedBusinessModels.Photo p) {
|
||||
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
|
||||
}
|
||||
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
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;
|
||||
import java.util.List;
|
||||
|
||||
/** 排车资料入口复用车队原照片记录;签收及BOL仍由派送单管理。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents")
|
||||
public class DispatchVehicleDocumentController {
|
||||
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.DISPATCH;
|
||||
private final OwnedBusinessQueryService queries;
|
||||
private final OwnedBusinessPhotoService photos;
|
||||
private final OwnedBusinessSupport access;
|
||||
/** 沿已有服务复用来源校验、文件归属、版本和命令账本。 */
|
||||
public DispatchVehicleDocumentController(OwnedBusinessQueryService queries,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) {
|
||||
this.queries=queries;this.photos=photos;this.access=access;
|
||||
}
|
||||
public record Documents(String dispatchNo,String recordVersion,boolean editable,List<Photo> photos) {}
|
||||
/** 只输出油耗资料,避免把车队签收照片当作派送签收文件。 */
|
||||
private Documents documents(Detail value) {
|
||||
return new Documents(value.row().dispatchNo(),value.row().recordVersion(),value.row().actions().upload().allowed(),
|
||||
value.photos().stream().filter(p->"FUEL".equals(p.category())).toList());
|
||||
}
|
||||
/** 排车列表权限包含车辆资料只读,不扩大派送单客户访问权限。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(documents(queries.detail(access.actor(user,SCOPE),dispatchNo,SCOPE)));
|
||||
}
|
||||
/** 上传和引用保存分开,沿原请求键防止重传。 */
|
||||
@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 PhotoSlot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(photos.upload(access.actor(user,SCOPE),dispatchNo,slot,idempotencyKey,file,SCOPE));
|
||||
}
|
||||
/** 只更新既有附加资料版本,不修改排车阶段或签收状态。 */
|
||||
@PutMapping("/{slot}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(documents(photos.bind(access.actor(user,SCOPE),dispatchNo,slot,request,SCOPE)));
|
||||
}
|
||||
/** 预览只读取本车当前已经绑定的照片。 */
|
||||
@GetMapping("/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) {
|
||||
var value=photos.content(access.actor(user,SCOPE),dispatchNo,slot,fileId,null,SCOPE);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(value.content());
|
||||
}
|
||||
/** 恢复仅返回油耗上传结果,不通过排车入口暴露其他运营命令。 */
|
||||
@GetMapping("/uploads/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
var actor=access.actor(user,SCOPE);
|
||||
access.require(actor,SCOPE.uploadPermission());
|
||||
var command=photos.command(actor,dispatchNo,idempotencyKey,SCOPE);
|
||||
if(!"UPLOAD_PHOTO".equals(command.action()) || !"SUCCEEDED".equals(command.state()) || command.result()==null)
|
||||
throw OwnedBusinessSupport.conflict("原上传结果尚未确认,请保留原请求稍后核对");
|
||||
var value=access.read(access.write(command.result()),Photo.class);SCOPE.requireSlot(value.slot());
|
||||
return ApiResponse.success(value);
|
||||
}
|
||||
}
|
||||
+3
-23
@@ -7,26 +7,8 @@ import java.util.List;
|
||||
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
|
||||
@Mapper
|
||||
public interface OwnedBusinessQueryMapper {
|
||||
/** 拼车须实收且来源仍在本车;标识按完整字节精确比较,512字节覆盖128字符utf8mb4,避免列排序规则冲突或截断。 */
|
||||
String POOLED_CARGO="""
|
||||
EXISTS (SELECT 1 FROM ll_fleet_external_booking b
|
||||
JOIN ll_warehouse_transfer_order incoming ON CAST(incoming.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
|
||||
AND CAST(incoming.transfer_no AS BINARY(512))=CAST(b.incoming_no AS BINARY(512))
|
||||
WHERE b.trip_id=d.trip_id AND CAST(b.dispatch_no AS BINARY(512))=CAST(d.dispatch_no AS BINARY(512)) AND b.offer_id IS NOT NULL
|
||||
AND b.status='BOUND' AND b.receipt_json IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM ll_dispatch_item cargo
|
||||
JOIN ll_inbound_plan_pallet_allocation allocation ON allocation.pallet_id=cargo.plan_pallet_id
|
||||
AND allocation.plan_id=cargo.plan_id AND CAST(allocation.customer_id AS BINARY(512))=CAST(cargo.customer_id AS BINARY(512))
|
||||
JOIN ll_inbound_plan_line line ON line.id=allocation.line_id AND line.plan_id=cargo.plan_id
|
||||
WHERE cargo.dispatch_order_id=d.id AND CAST(cargo.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
|
||||
AND CAST(line.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
|
||||
AND CAST(line.inbound_code AS BINARY(512))=CAST(incoming.inbound_code AS BINARY(512))
|
||||
AND cargo.warehouse_pallet_id IS NOT NULL AND cargo.package_count>0))
|
||||
""";
|
||||
String SOURCE="""
|
||||
SELECT
|
||||
"""+POOLED_CARGO+" AS pooled_cargo,"+"""
|
||||
d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
|
||||
SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
|
||||
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
|
||||
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
|
||||
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
|
||||
@@ -38,12 +20,10 @@ public interface OwnedBusinessQueryMapper {
|
||||
String FILTER="""
|
||||
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
|
||||
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
|
||||
AND o.vehicle_ownership IN ('OWNED','DISPATCHED')
|
||||
AND o.vehicle_ownership=#{q.scope.ownership}
|
||||
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
|
||||
AND o.loading_completed_at=d.loading_completed_at
|
||||
AND (o.resource_mode='MASTER' OR (o.vehicle_ownership='DISPATCHED' AND o.resource_mode='EXTERNAL'))
|
||||
AND <if test='!q.scope.pooledCargo'>NOT</if>
|
||||
"""+POOLED_CARGO+"""
|
||||
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
|
||||
<if test='q.from != null'> AND d.planned_departure_at >= #{q.from}</if>
|
||||
<if test='q.to != null'> AND d.planned_departure_at < #{q.to}</if>
|
||||
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
||||
|
||||
-1
@@ -14,7 +14,6 @@ public final class OwnedBusinessRows {
|
||||
private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
|
||||
}
|
||||
@Data public static class Source {
|
||||
private Boolean pooledCargo;
|
||||
private Long id,recordVersion;
|
||||
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
|
||||
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
|
||||
|
||||
+10
-12
@@ -6,23 +6,21 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
|
||||
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
|
||||
public enum OwnedBusinessScope {
|
||||
OWNED(false,"platform:fleet:owned-business:"),
|
||||
OUTSOURCED(true,"platform:fleet:outsourced-business:"),
|
||||
DISPATCH(false,"platform:overseas-warehouse:dispatch:");
|
||||
private final boolean pooledCargo;
|
||||
private final String prefix;
|
||||
/** 业务入口按是否实际接入拼车货物分类,与车辆归属独立。 */
|
||||
OwnedBusinessScope(boolean pooledCargo,String prefix){this.pooledCargo=pooledCargo;this.prefix=prefix;}
|
||||
/** SQL绑定服务端固定分类,不接受浏览器指定作用域。 */
|
||||
public boolean isPooledCargo(){return pooledCargo;}
|
||||
OWNED("OWNED","platform:fleet:owned-business:"),
|
||||
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
|
||||
private final String ownership,prefix;
|
||||
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
|
||||
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
|
||||
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
|
||||
public String getOwnership(){return ownership;}
|
||||
/** 查看权限包含本作用域详情、历史和照片读取。 */
|
||||
public String listPermission(){return prefix+"list";}
|
||||
/** 运营写权限独立,外配权限不映射成自有权限。 */
|
||||
public String editPermission(){return prefix+"edit";}
|
||||
/** 照片写权限独立,不扩大到原排车。 */
|
||||
public String uploadPermission(){return prefix+(this==DISPATCH?"edit":"upload");}
|
||||
public String uploadPermission(){return prefix+"upload";}
|
||||
/** 外配只支持装车五框,服务调用同样不能绕过。 */
|
||||
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()) || this==DISPATCH && !"FUEL".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
|
||||
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
|
||||
/** 不存在只显示当前业务归属,不泄露内部身份。 */
|
||||
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,this==DISPATCH?"排车单不存在":"排车单不存在或不属于当前"+(this==OWNED?"自有业务":"外配任务"));}
|
||||
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
|
||||
}
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.port;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 整车装车资料公开端口;排车只组合资料事实,不读取车队持久化实现。 */
|
||||
public interface DispatchLoadingPhotoPort {
|
||||
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
|
||||
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
||||
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
|
||||
record State(String recordVersion,List<Photo> photos) {}
|
||||
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
|
||||
record Download(Photo photo,byte[] content) {}
|
||||
/** 只读取原整车附加资料版本与五框,不初始化记录。 */
|
||||
State read(long dispatchId);
|
||||
/** 上传保存原请求意图;不提前计入已保存照片。 */
|
||||
Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file);
|
||||
/** 仅原操作者可恢复原上传结果。 */
|
||||
Photo recover(long dispatchId,String operator,String key);
|
||||
/** 先校验原槽位、完整载荷与操作者;无原成功命令时返回null。 */
|
||||
Documents replayBind(long dispatchId,String operator,Slot slot,BindPhoto request);
|
||||
/** 调用者已持排车父锁;保存照片、资料版本及历史但不单独提交事务。 */
|
||||
State bind(long dispatchId,String operator,Slot slot,BindPhoto request);
|
||||
/** 与绑定及自动发车共同提交完整原结果。 */
|
||||
void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result);
|
||||
/** 文件只可从本车当前已绑定槽位下载。 */
|
||||
Download content(long dispatchId,Slot slot,long fileId);
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||
|
||||
/** 在原照片表和命令账本实现装车资料端口,不反向调用排车履约服务。 */
|
||||
@Service
|
||||
public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
|
||||
private final OwnedBusinessRepository repository;
|
||||
private final OwnedBusinessCommandService commands;
|
||||
private final OwnedBusinessProjection projection;
|
||||
private final OwnedBusinessSupport codec;
|
||||
private final OwnedBusinessFileGateway files;
|
||||
/** 复用现有照片、版本、历史及文件归属校验。 */
|
||||
public DispatchLoadingPhotoAdapter(OwnedBusinessRepository repository,OwnedBusinessCommandService commands,
|
||||
OwnedBusinessProjection projection,OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
|
||||
this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
|
||||
}
|
||||
public record Binding(Slot slot,BindPhoto request) {}
|
||||
/** GET只读;首次保存前资料版本为0。 */
|
||||
@Override public State read(long id) {
|
||||
var row=repository.record(id);
|
||||
return new State(row==null?"0":row.getRecordVersion().toString(),projection.photos(row).stream()
|
||||
.filter(p->"LOADING".equals(p.category())).map(this::publicPhoto).toList());
|
||||
}
|
||||
/** 先落稳定上传意图,再请求File;未知结果不允许换键重传。 */
|
||||
@Override public Photo upload(long id,String operator,Slot slot,String key,MultipartFile multipart) {
|
||||
codec.key(key);var originalSlot=slot(slot);
|
||||
if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");}
|
||||
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
|
||||
HttpOwnedBusinessFileGateway.validate(payload);
|
||||
var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
|
||||
var reservation=commands.reserveUpload(id,operator(operator),key,identity);
|
||||
if(!reservation.created())return uploaded(reservation.command());
|
||||
try {
|
||||
var result=photo(originalSlot,files.upload(id,originalSlot,operator,payload));
|
||||
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return publicPhoto(result);
|
||||
} catch(RuntimeException failure) {
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");}
|
||||
catch(RuntimeException recording){failure.addSuppressed(recording);}
|
||||
throw conflict("照片上传结果未知,请保留原请求键核对");
|
||||
}
|
||||
}
|
||||
/** 上传结果仅限本车原操作者及装车槽位。 */
|
||||
@Override public Photo recover(long id,String operator,String key) {
|
||||
codec.key(key);var command=commands.existing(id,key,operator(operator));
|
||||
if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求");return uploaded(command);
|
||||
}
|
||||
/** 完整原结果先于当前资料版本比较,防止重复推进。 */
|
||||
@Override public Documents replayBind(long id,String operator,Slot slot,BindPhoto request) {
|
||||
validate(slot,request);var old=commands.existing(id,request.idempotencyKey(),operator(operator));
|
||||
if(old==null)return null;
|
||||
commands.same(old,"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request));
|
||||
if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认");return codec.read(old.getResultJson(),Documents.class);
|
||||
}
|
||||
/** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */
|
||||
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
|
||||
public State bind(long id,String operator,Slot slot,BindPhoto request) {
|
||||
validate(slot,request);long version=codec.version(request.recordVersion());var originalSlot=slot(slot);
|
||||
var verified=photo(originalSlot,files.metadata(id,originalSlot,codec.version(request.fileId())));
|
||||
var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(id);
|
||||
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
|
||||
var row=repository.lock(seed);
|
||||
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
|
||||
var photos=new EnumMap<PhotoSlot,OwnedBusinessModels.Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
|
||||
photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
|
||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
|
||||
history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
|
||||
history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),projection.photos(row),operator)));repository.history(history);
|
||||
return read(id);
|
||||
}
|
||||
/** 保存调用者提供的最终阶段,命令与资料、派送同事务提交。 */
|
||||
@Override @Transactional(propagation=Propagation.MANDATORY)
|
||||
public void recordBind(long id,String operator,Slot slot,BindPhoto request,Documents result) {
|
||||
commands.success(id,operator(operator),request.idempotencyKey(),"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request),result);
|
||||
}
|
||||
/** 下载仅允许当前已保存引用,不接受未绑定上传或其他车文件。 */
|
||||
@Override public Download content(long id,Slot slot,long fileId) {
|
||||
var originalSlot=slot(slot);var photo=projection.photos(repository.record(id)).stream()
|
||||
.filter(p->p.slot()==originalSlot && String.valueOf(fileId).equals(p.fileId())).findFirst()
|
||||
.orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片"));
|
||||
return new Download(publicPhoto(photo),files.content(id,originalSlot,fileId));
|
||||
}
|
||||
/** 原五框技术名保持文件归属,拒绝油耗框作为装车资料。 */
|
||||
private PhotoSlot slot(Slot slot){if(slot==null)throw bad("请选择有效的装车照片框");return PhotoSlot.valueOf(slot.name());}
|
||||
/** 版本0合法,文件编号必须为正且请求键不得缺失。 */
|
||||
private void validate(Slot slot,BindPhoto request) {
|
||||
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
|
||||
if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
|
||||
}
|
||||
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
|
||||
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
|
||||
/** 回执必须是已知成功上传,其他类别或未明确结果不得充数。 */
|
||||
private Photo uploaded(OwnedBusinessRows.Command command) {
|
||||
if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null)throw conflict("原上传结果尚未确认,请保留原请求");
|
||||
var value=codec.read(command.getResultJson(),OwnedBusinessModels.Photo.class);
|
||||
if(!"LOADING".equals(value.category()))throw bad("原上传不是装车照片");return publicPhoto(value);
|
||||
}
|
||||
/** 外部事实仅投影照片字段,不暴露持久化记录。 */
|
||||
private Photo publicPhoto(OwnedBusinessModels.Photo photo) {
|
||||
return new Photo(Slot.valueOf(photo.slot().name()),photo.category(),photo.fileId(),photo.fileName(),photo.contentType(),photo.sizeBytes(),photo.uploadedAt(),photo.savedAt());
|
||||
}
|
||||
/** 上传回执不伪造保存时间。 */
|
||||
private OwnedBusinessModels.Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) {
|
||||
return new OwnedBusinessModels.Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -53,7 +53,8 @@ public class OwnedBusinessProjection {
|
||||
var resources=codec.read(source.getResourcesJson(),Resources.class);
|
||||
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
|
||||
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
|
||||
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
|
||||
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|
||||
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
|
||||
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
|
||||
var destinations=new LinkedHashMap<String,Destination>();
|
||||
for(var item:cargo) {
|
||||
@@ -67,7 +68,7 @@ public class OwnedBusinessProjection {
|
||||
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
|
||||
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
|
||||
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
|
||||
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),source.getOwnedVehicleOwnership()),
|
||||
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
|
||||
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
|
||||
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
|
||||
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
|
||||
|
||||
+5
-8
@@ -17,7 +17,7 @@ import java.util.stream.Collectors;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
|
||||
/** 装车后按实际拼车货物分组;车辆归属仅保留原装车证据。 */
|
||||
/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */
|
||||
@Service
|
||||
public class OwnedBusinessQueryService {
|
||||
private final OwnedBusinessRepository repository;
|
||||
@@ -61,7 +61,7 @@ public class OwnedBusinessQueryService {
|
||||
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
|
||||
/** 外配入口只读实际拼车任务,不能借自有权限取得原详情。 */
|
||||
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
|
||||
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
|
||||
@@ -82,7 +82,7 @@ public class OwnedBusinessQueryService {
|
||||
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
|
||||
}
|
||||
/** 共享附加表按实际拼车分类隔离,不接受浏览器业务域参数。 */
|
||||
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
|
||||
public Source requireScoped(String number,OwnedBusinessScope scope) {
|
||||
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
|
||||
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||
@@ -91,16 +91,13 @@ public class OwnedBusinessQueryService {
|
||||
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
|
||||
private void eligible(Source source,OwnedBusinessScope scope) {
|
||||
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||
if(!Set.of("OWNED","DISPATCHED").contains(source.getOwnedVehicleOwnership()))throw conflict("装车车辆归属不合法");
|
||||
if(source.getPooledCargo()==null)throw conflict("本单缺少实际拼车分类依据");
|
||||
if(scope!=OwnedBusinessScope.DISPATCH && scope.isPooledCargo()!=source.getPooledCargo())throw scope.missing();
|
||||
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
|
||||
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|
||||
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
|
||||
if(scope==OwnedBusinessScope.DISPATCH && !Set.of("DELIVERING","DELIVERED").contains(source.getStage()))throw conflict("仅派送中和配送完成可管理车辆资料");
|
||||
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
|
||||
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
|
||||
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
|
||||
boolean external="DISPATCHED".equals(source.getOwnedVehicleOwnership()) && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
|
||||
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
|
||||
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
|
||||
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
|
||||
|
||||
@@ -39,9 +39,6 @@ public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> {
|
||||
/** 解除计划中的取货关联,保留历史后允许再次安排。 */
|
||||
@Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
|
||||
int unassign(@Param("tripNo") String tripNo);
|
||||
/** 原单同时取消时保留车次关联供详情和审计追溯,不再回到待安排。 */
|
||||
@Update("UPDATE ll_fleet_pickup_task SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
|
||||
int cancelTasks(@Param("tripNo") String tripNo);
|
||||
/** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */
|
||||
@Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})")
|
||||
int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip,
|
||||
|
||||
+2
-8
@@ -24,10 +24,7 @@ public final class FleetPickupModels {
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Command(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@NotBlank @Size(max=1000) String reason,List<@NotNull @Positive Long> evidenceFileIds) {
|
||||
/** 无照片的非送仓命令保持原调用形式。 */
|
||||
public Command(Long recordVersion,String idempotencyKey,String reason){this(recordVersion,idempotencyKey,reason,null);}
|
||||
}
|
||||
@NotBlank @Size(max=1000) String reason) { }
|
||||
public record Pickup(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@Min(1) @Max(5000) int actualPackages,
|
||||
@@ -38,9 +35,6 @@ public final class FleetPickupModels {
|
||||
String status, String tripNo, String vehiclePlate, String driverName, String driverPhone,
|
||||
Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt,
|
||||
String remark, List<Evidence> evidence) { }
|
||||
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes,String phase) {
|
||||
/** 旧取货凭证明确归入取货阶段,不冒充到仓照片。 */
|
||||
public Evidence(String fileId,String fileName,String contentType,long sizeBytes){this(fileId,fileName,contentType,sizeBytes,"PICKUP");}
|
||||
}
|
||||
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user