Compare commits

..
Author SHA1 Message Date
ouyangxq 9cb60517ba fix: align actual port arrival with latest observation 2026-10-21 16:50:48 +08:00
327 changed files with 2399 additions and 10765 deletions
+3 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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 基础镜像')
}
+3 -8
View File
@@ -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`。
+1 -2
View File
@@ -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}
-226
View File
@@ -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 }
}
-4
View File
@@ -52,10 +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与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
订舱委/实资料在 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一空库基线同步维护:订舱主表增加条款、包装、品名、唛头和委/实三项货量,关系方增加邮箱、税号;已放舱主表触发器仅允许实际货量及更新审计元数据变化,运输、预报和创建信息冻结。匹配发布单元为 Firstleg 与 Admin;Print 继续使用现有模板。本轮只准备初始化定义,不执行 SQL、不回填测试存量、不提供新旧兼容。已有测试库须另行审阅、授权结构更新,不能重放完整空库基线。
## 唯一入口与区域边界
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
@@ -3442,7 +3442,7 @@ CREATE TABLE fl_mojia_measurement_receipt (
CREATE TABLE fl_ocean_booking (
id bigint unsigned NOT NULL COMMENT '海运订舱ID',
booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号',
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键',
customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
@@ -3465,17 +3465,6 @@ CREATE TABLE fl_ocean_booking (
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间',
remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注',
freight_term VARCHAR(64) NULL COMMENT '运费条款',
service_term VARCHAR(64) NULL COMMENT '运输条款',
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
@@ -3490,14 +3479,6 @@ CREATE TABLE fl_ocean_booking (
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)),
CONSTRAINT chk_ocean_booking_cargo CHECK (
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))),
CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
@@ -3584,8 +3565,6 @@ CREATE TABLE fl_ocean_booking_party (
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照',
contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照',
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE,
@@ -6047,49 +6026,9 @@ CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_book
END IF;
END$$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
IF OLD.status = 'RELEASED' AND NOT (
(NEW.id <=> OLD.id)
AND (NEW.booking_no <=> OLD.booking_no)
AND (NEW.status <=> OLD.status)
AND (NEW.customer_id <=> OLD.customer_id)
AND (NEW.customer_name <=> OLD.customer_name)
AND (NEW.channel_code <=> OLD.channel_code)
AND (NEW.channel_name <=> OLD.channel_name)
AND (NEW.warehouse_id <=> OLD.warehouse_id)
AND (NEW.warehouse_name <=> OLD.warehouse_name)
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
AND (NEW.carrier_code <=> OLD.carrier_code)
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
AND (NEW.vessel_name <=> OLD.vessel_name)
AND (NEW.voyage_no <=> OLD.voyage_no)
AND (NEW.departure_port_code <=> OLD.departure_port_code)
AND (NEW.departure_port_name <=> OLD.departure_port_name)
AND (NEW.destination_port_code <=> OLD.destination_port_code)
AND (NEW.destination_port_name <=> OLD.destination_port_name)
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
AND (NEW.remark <=> OLD.remark)
AND (NEW.freight_term <=> OLD.freight_term)
AND (NEW.service_term <=> OLD.service_term)
AND (NEW.package_unit <=> OLD.package_unit)
AND (NEW.cargo_description <=> OLD.cargo_description)
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
AND (NEW.declared_package_count <=> OLD.declared_package_count)
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
AND (NEW.created_by <=> OLD.created_by)
AND (NEW.created_by_name <=> OLD.created_by_name)
AND (NEW.created_at <=> OLD.created_at)
AND NEW.version = OLD.version + 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -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);
@@ -1,60 +0,0 @@
package com.baiyue.finance.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
@Mapper
public interface FinanceFeeSourceReadMapper {
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))";
String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
" AND f.status&lt;&gt;'SUPERSEDED' AND NOT EXISTS(SELECT 1 FROM fin_fee_source newer" +
" WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type" +
" AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version&gt;f.source_fee_version)" +
"</if>" +
"<if test='customerId != null'> AND f.customer_id=#{customerId}</if>" +
"<if test='direction != null'> AND f.direction=#{direction}</if>" +
"<if test='reviewStatus != null'> AND f.review_status=#{reviewStatus}</if>" +
"<if test='status != null'> AND " + STATUS + "=#{status}</if>" +
"<if test='statementGenerated != null'> AND " + GENERATED + "=#{statementGenerated}</if>" +
"<if test='dateFrom != null'> AND " + BUSINESS_DATE + "&gt;=#{dateFrom}</if>" +
"<if test='dateTo != null'> AND " + BUSINESS_DATE + "&lt;=#{dateTo}</if>" +
"<if test='keyword != null'> AND (f.source_no LIKE CONCAT('%',#{keyword},'%')" +
" OR f.fee_item_name LIKE CONCAT('%',#{keyword},'%')" +
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',#{keyword},'%')" +
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',#{keyword},'%')" +
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',#{keyword},'%')" +
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',#{keyword},'%'))</if>";
/** 筛选和计数共用当前版本条件,不能先分页再去重。 */
@Select("<script>SELECT COUNT(*)" + FILTER + "</script>")
long count(Map<String, Object> filters);
/** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS + " effective_status" + FILTER +
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<Map<String, Object>> list(Map<String, Object> filters);
String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" +
" AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" +
" AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" +
" AND f.source_fee_version&lt;=anchor.source_fee_version";
/** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */
@Select("<script>SELECT f.id,f.source_fee_version,f.amount,f.currency_code,f.created_at" + LINEAGE +
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId}" +
" AND f.source_system IN ('FIRSTLEG','LASTLEG') ORDER BY f.source_fee_version,f.id</script>")
List<Map<String, Object>> versions(@Param("customerId") long customerId, @Param("id") long id);
/** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */
@Select("<script>SELECT a.id,a.created_at,a.action,a.operator_name_snapshot,a.reason,a.before_data,a.after_data" + LINEAGE +
" JOIN fin_audit_log a ON a.tenant_id=f.customer_id AND a.resource_type='FIN_FEE_SOURCE' AND a.resource_id=f.id" +
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId} ORDER BY a.created_at DESC,a.id DESC</script>")
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("id") long id);
}
@@ -1,145 +0,0 @@
package com.baiyue.finance.repository;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
import com.baiyue.finance.service.FinanceEventException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.LinkedCaseInsensitiveMap;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** 费用当前投影及操作历史;版本记录只读,不覆盖账单引用的不可变快照。 */
@Repository
public class FinanceFeeSourceReadRepository {
private final FinanceFeeSourceReadMapper mapper;
private final ObjectMapper json;
/** 注入正式持久化入口与费用快照解析器。 */
public FinanceFeeSourceReadRepository(FinanceFeeSourceReadMapper mapper, ObjectMapper json) {
this.mapper = mapper;
this.json = json;
}
/** 组装统一查询参数,让计数和分页使用相同来源身份与筛选条件。 */
public Map<String, Object> filters(Long customerId, String direction, String status, String reviewStatus,
Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) {
Map<String, Object> values = new HashMap<>();
values.put("customerId", customerId); values.put("direction", direction); values.put("status", status);
values.put("reviewStatus", reviewStatus); values.put("statementGenerated", generated);
values.put("dateFrom", from); values.put("dateTo", to); values.put("keyword", keyword);
values.put("limit", size); values.put("offset", (page - 1) * size);
return values;
}
/** 计数只包含所筛选的当前费用,历史状态专项查询除外。 */
public long count(Map<String, Object> filters) { return mapper.count(filters); }
/** 当前费用映射沿用原币、数量、客户等正式字段。 */
public List<FeeSourceSummary> list(Map<String, Object> filters) {
return mapper.list(filters).stream().map(this::summary).toList();
}
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
public List<OperationLog> logs(long customerId, long id) {
List<OperationLog> result = new ArrayList<>();
Map<String, Object> previous = null;
for (Map<String, Object> raw : mapper.versions(customerId, id)) {
Map<String, Object> row = row(raw);
String version = "来源版本 " + row.get("source_fee_version");
String amount = amount(row) + " " + row.get("currency_code");
// 同步时间与原业务操作者不能混用,原业务审批仍以来源系统记录为准。
result.add(new OperationLog(time(row.get("created_at")),
previous == null ? "FEE_SOURCE_PUBLISHED" : "FEE_SOURCE_VERSION_ADJUST",
"系统同步", "SUCCESS", previous == null ? version + ";金额 " + amount
: "来源版本 " + previous.get("source_fee_version") + " → " + row.get("source_fee_version")
+ ";金额 " + amount(previous) + " → " + amount));
previous = row;
}
// 金额差分仍按版本升序计算;展示先反转版本,同毫秒时稳定排序保留新版本在前。
java.util.Collections.reverse(result);
for (Map<String, Object> raw : mapper.audits(customerId, id)) {
Map<String, Object> row = row(raw);
String remark = text(row.get("reason"));
JsonNode before = parse(row.get("before_data"));
JsonNode after = parse(row.get("after_data"));
if (before.hasNonNull("amount") && after.hasNonNull("amount")
&& before.path("amount").decimalValue().compareTo(after.path("amount").decimalValue()) != 0) {
remark = (remark == null ? "" : remark + ";") + "金额 " + before.path("amount").asText()
+ " → " + after.path("amount").asText() + " " + after.path("currency").asText("CNY");
}
result.add(new OperationLog(time(row.get("created_at")), text(row.get("action")),
text(row.get("operator_name_snapshot")), "SUCCESS", remark));
}
// 时间排序保持同毫秒版本的新到旧顺序,以及 Mapper 返回的审计 ID 倒序。
result.sort(Comparator.comparing(OperationLog::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
return List.copyOf(result);
}
/** 将持久化快照转换为费用列表 DTO,不从其他客户或来源补齐业务事实。 */
private FeeSourceSummary summary(Map<String, Object> raw) {
Map<String, Object> row = row(raw);
JsonNode snapshot = parse(row.get("source_snapshot"));
BigDecimal amount = decimal(row.get("amount"));
BigDecimal originalAmount = first(decimal(text(snapshot, "originalAmount")), amount);
BigDecimal quantity = decimal(text(snapshot, "chargeableQuantity"));
BigDecimal unitPrice = first(decimal(text(snapshot, "unitPrice")), quantity != null && quantity.signum() > 0
? originalAmount.divide(quantity, 6, RoundingMode.HALF_UP) : null);
return new FeeSourceSummary(number(row.get("id")), text(row.get("source_system")), text(row.get("source_type")),
first(text(row.get("source_no")), text(snapshot, "sourceBusinessId")), number(row.get("source_fee_id")),
((Number) row.get("source_fee_version")).intValue(), text(row.get("direction")), number(row.get("customer_id")),
text(snapshot, "customerName"), positiveLong(text(snapshot, "supplierId")), text(snapshot, "supplierName"),
nullableLong(row.get("order_id")), nullableLong(row.get("inbound_id")), text(snapshot, "inboundCode"),
nullableLong(row.get("container_id")), text(snapshot, "containerNo"),
first(date(text(snapshot, "businessDate")), LocalDate.of(1970, 1, 1)), text(row.get("fee_item_name")),
quantity, text(snapshot, "quantityUnit"), unitPrice, date(text(snapshot, "periodStart")), date(text(snapshot, "periodEnd")),
amount, text(row.get("currency_code")), first(text(snapshot, "originalCurrency"), text(row.get("currency_code"))),
originalAmount, first(decimal(text(snapshot, "exchangeRate")), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
first(text(row.get("review_status")), "DRAFT"), text(row.get("effective_status")),
Boolean.TRUE.equals(row.get("statement_generated")) || row.get("statement_generated") instanceof Number value && value.intValue() != 0);
}
/** 保留不同数据库驱动的列名大小写读取语义。 */
private static Map<String, Object> row(Map<String, Object> source) {
Map<String, Object> result = new LinkedCaseInsensitiveMap<>(); result.putAll(source); return result;
}
/** 解析不可变快照,损坏数据失败关闭。 */
private JsonNode parse(Object value) {
try { return value == null ? json.createObjectNode() : json.readTree(value.toString()); }
catch (Exception failure) { throw new FinanceEventException("FIN-DATA-500", "费用来源快照损坏"); }
}
/** 格式化记录金额,保留定点精度。 */
private static String amount(Map<String, Object> row) { return decimal(row.get("amount")).toPlainString(); }
/** 读取驱动返回的标准时间。 */
private static LocalDateTime time(Object value) { return value instanceof Timestamp time ? time.toLocalDateTime() : (LocalDateTime) value; }
/** 读取数字主键,仅用于数据引用。 */
private static long number(Object value) { return ((Number) value).longValue(); }
/** 保留未关联的可空引用。 */
private static Long nullableLong(Object value) { return value == null ? null : number(value); }
/** 只接受合法的正整型供应商引用。 */
private static Long positiveLong(String value) {
try { long parsed = Long.parseLong(value); return parsed > 0 ? parsed : null; }
catch (NumberFormatException failure) { return null; }
}
/** 读取可空金额,不经过浮点转换。 */
private static BigDecimal decimal(Object value) { return value == null ? null : new BigDecimal(value.toString()); }
/** 读取可空业务日期。 */
private static LocalDate date(String value) { return value == null ? null : LocalDate.parse(value); }
/** 保持空字段的缺失语义。 */
private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString(); }
/** 读取快照字段。 */
private static String text(JsonNode node, String field) { return node.path(field).isNull() || node.path(field).isMissingNode() ? null : text(node.path(field).asText()); }
/** 优先使用来源字段,其次使用已有正式默认值。 */
private static <T> T first(T value, T fallback) { return value == null ? fallback : value; }
}
@@ -1,10 +1,10 @@
package com.baiyue.finance.service;
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
@@ -44,19 +44,19 @@ public class FinancePhase1OperationService {
private final ObjectMapper objectMapper;
private final FirstlegFeeSourceClient firstleg;
private final String internalToken;
private final FinanceFeeSourceReadRepository feeSources;
/** 注入费用来源查询及现有业务依赖。 */
@Autowired
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FirstlegFeeSourceClient firstleg,
@Value("${baiyue.security.internal-service-token}") String internalToken,
FinanceFeeSourceReadRepository feeSources) {
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.firstleg = firstleg;
this.internalToken = internalToken;
this.feeSources = feeSources;
}
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, null, "");
}
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
@@ -163,7 +163,6 @@ public class FinancePhase1OperationService {
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
}
/** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
String direction, String sourceStatus,
String reviewStatus,
@@ -179,9 +178,34 @@ public class FinancePhase1OperationService {
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
List<Object> args = new ArrayList<>();
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
String normalizedKeyword = blankToNull(keyword);
if (normalizedKeyword != null) {
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
}
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
List<Object> queryArgs = new ArrayList<>(args);
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
@@ -315,7 +339,6 @@ public class FinancePhase1OperationService {
return after;
}
/** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
JsonNode snapshot = parse(rs.getString("source_snapshot"));
long id = rs.getLong("id");
@@ -333,7 +356,33 @@ public class FinancePhase1OperationService {
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id));
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
JsonNode snapshot = parse(rs.getString("source_snapshot"));
boolean generated = rs.getBoolean("statement_generated");
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = money(rs, "amount");
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0
? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"),
rs.getString("effective_status"), generated);
}
private List<OperationLog> logs(long customerId, long id) {
return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) ->
new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id);
}
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
@@ -17,7 +17,6 @@ class FinanceContractTest {
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
}
/** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */
@Test
void controllersUseFrozenFinancePaths() throws Exception {
assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]);
@@ -41,8 +40,7 @@ class FinanceContractTest {
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
String feeReads = Files.readString(Path.of("src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java"));
assertTrue(feeReads.contains("bl.source_id=f.id"));
assertTrue(operations.contains("bl.source_id=f.id"));
assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")"));
assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
@@ -1,141 +0,0 @@
package com.baiyue.finance;
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 隔离数据库执行正式 Mapper,验证版本归并与历史不会改变金额和客户归属。 */
public class FinanceFeeSourceReadMapperTest {
/** 应收和应付均以来源身份替换当前展示,同名费用、其他来源类型和系统保持独立。 */
@ParameterizedTest
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
void currentProjectionAndHistoryKeepStableFeeIdentity(String direction) {
var data = new DriverManagerDataSource("jdbc:h2:mem:fee-current-" + UUID.randomUUID() + ";MODE=MySQL", "sa", "");
var jdbc = new JdbcTemplate(data);
// 保持一个会话存活;Mapper 使用同一隔离数据库,绝不访问共享测试环境。
try (var connection = data.getConnection()) {
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'");
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)");
jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)");
insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00");
insert(jdbc, 3, "FIRSTLEG", "INBOUND_FEE", 701, 1, 9, direction, "AVAILABLE", "80.00");
insert(jdbc, 4, "FIRSTLEG", "INBOUND_FEE", 702, 1, 9, direction, "AVAILABLE", "50.00");
insert(jdbc, 5, "FIRSTLEG", "INBOUND_FEE", 702, 2, 9, direction, "VOIDED", "70.00");
insert(jdbc, 6, "LASTLEG", "INBOUND_FEE", 700, 1, 9, direction, "AVAILABLE", "33.00");
insert(jdbc, 7, "FIRSTLEG", "CONTAINER_FEE", 700, 1, 9, direction, "AVAILABLE", "44.00");
insert(jdbc, 8, "FIRSTLEG", "INBOUND_FEE", 703, 1, 10, direction, "AVAILABLE", "55.00");
jdbc.update("INSERT INTO fin_audit_log VALUES(1,9,'FIN_FEE_SOURCE',1,'FEE_SOURCE_REVIEW_APPROVE','审核员','原费用审核',NULL,NULL,'2026-10-21 10:01:30')");
jdbc.update("INSERT INTO fin_audit_log VALUES(2,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_ADJUST','财务员','更正费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:03:00')");
jdbc.update("INSERT INTO fin_audit_log VALUES(3,9,'FIN_FEE_SOURCE',3,'FEE_SOURCE_ADJUST','其他费用','不能合并',NULL,NULL,'2026-10-21 10:04:00')");
jdbc.update("INSERT INTO fin_audit_log VALUES(4,10,'FIN_FEE_SOURCE',1,'FEE_SOURCE_ADJUST','其他客户','不能越界',NULL,NULL,'2026-10-21 10:04:00')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), data));
configuration.addMapper(FinanceFeeSourceReadMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
var repository = new FinanceFeeSourceReadRepository(session.getMapper(FinanceFeeSourceReadMapper.class), new ObjectMapper());
var filters = repository.filters(9L, direction, null, "APPROVED", false,
LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 31), "操作费", 1, 20);
assertThat(repository.count(filters)).isEqualTo(5);
var rows = repository.list(filters);
assertThat(rows).extracting(row -> row.feeSourceId()).containsExactly(7L, 6L, 5L, 3L, 2L);
assertThat(rows.stream().map(row -> row.amount()).reduce(BigDecimal.ZERO, BigDecimal::add))
.isEqualByComparingTo("347.00");
assertThat(rows).allSatisfy(row -> {
assertThat(row.direction()).isEqualTo(direction);
assertThat(row.customerId()).isEqualTo(9);
assertThat(row.supplierId()).isEqualTo(79);
assertThat(row.inboundCode()).isEqualTo("IN-9");
assertThat(row.businessDate()).isEqualTo(LocalDate.of(2026, 10, 21));
assertThat(row.originalAmount()).isEqualByComparingTo("200");
assertThat(row.unitPrice()).isEqualByComparingTo("20");
});
filters.put("limit", 2);
filters.put("offset", 2);
assertThat(repository.count(filters)).isEqualTo(5);
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(5L, 3L);
filters.put("limit", 20); filters.put("offset", 0); filters.put("status", "AVAILABLE");
assertThat(repository.count(filters)).isEqualTo(4);
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).doesNotContain(4L, 5L);
filters.put("status", "SUPERSEDED");
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(1L);
var logs = repository.logs(9, 2);
assertThat(logs).hasSize(4);
assertThat(logs).extracting(log -> log.action()).containsExactly(
"FEE_SOURCE_ADJUST", "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_PUBLISHED");
assertThat(logs.get(0).remark()).isEqualTo("更正费用;金额 100 → 120 CNY");
assertThat(logs.get(1).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
assertThat(logs).noneSatisfy(log -> assertThat(log.operatorName()).startsWith("其他"));
assertThat(repository.logs(9, 2)).isEqualTo(logs);
assertThat(repository.logs(9, 1)).hasSize(2);
assertThat(repository.logs(10, 2)).isEmpty();
// 同毫秒必须先展示新版本;审计仍按 ID 倒序,不能反转金额差分或突破旧版本锚点。
jdbc.update("UPDATE fin_fee_source SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
jdbc.update("UPDATE fin_audit_log SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
jdbc.update("INSERT INTO fin_audit_log VALUES(6,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_REVIEW_APPROVE','审核员','同毫秒后审计',NULL,NULL,'2026-10-21 10:03:00.123')");
session.clearCache();
var sameTimeLogs = repository.logs(9, 2);
assertThat(sameTimeLogs).extracting(log -> log.action()).containsExactly(
"FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE",
"FEE_SOURCE_ADJUST", "FEE_SOURCE_REVIEW_APPROVE");
assertThat(sameTimeLogs.get(0).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
assertThat(sameTimeLogs.get(1).remark()).contains("来源版本 1;金额 100.000000 CNY");
assertThat(sameTimeLogs.subList(2, 5)).extracting(log -> log.remark()).containsExactly(
"同毫秒后审计", "更正费用;金额 100 → 120 CNY", "原费用审核");
assertThat(repository.logs(9, 2)).isEqualTo(sameTimeLogs);
assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)");
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
assertThat(repository.count(filters)).isEqualTo(1);
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
jdbc.update("INSERT INTO fin_audit_log VALUES(5,9,'FIN_FEE_SOURCE',9,'FEE_SOURCE_ADJUST','财务员','更正人工费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:05:00')");
assertThat(repository.logs(9, 9)).singleElement().satisfies(log -> {
assertThat(log.action()).isEqualTo("FEE_SOURCE_ADJUST");
assertThat(log.remark()).isEqualTo("更正人工费用;金额 100 → 120 CNY");
});
}
} catch (java.sql.SQLException failure) { throw new AssertionError(failure); }
}
/** 准备具有相同名称、不同稳定来源的隔离费用快照。 */
private static void insert(JdbcTemplate jdbc, long id, String system, String type, long fee, int version,
long customer, String direction, String status, String amount) {
jdbc.update("INSERT INTO fin_fee_source(id,source_system,source_type,source_fee_id,source_fee_version,customer_id,direction,source_no,fee_item_name,amount,currency_code,review_status,status,source_snapshot,created_at) VALUES(?,?,?,?,?,?,?,'IN-9','操作费',?,'CNY','APPROVED',?,?,?)",
id, system, type, fee, version, customer, direction, new BigDecimal(amount), status,
"{\"businessDate\":\"2026-10-21\",\"supplierId\":79,\"inboundCode\":\"IN-9\",\"chargeableQuantity\":10,\"originalAmount\":200}",
java.sql.Timestamp.valueOf("2026-10-21 10:0" + version + ":00"));
}
/** H2 仅补足 MySQL JSON 读取函数,实际筛选、关联和分页仍执行正式 SQL。 */
public static String jsonExtract(String value, String path) throws Exception {
var node = new ObjectMapper().readTree(value).path(path.substring(2));
return node.isMissingNode() || node.isNull() ? null : node.toString();
}
/** 对测试 JSON 标量解引号,保持 MySQL 空值语义。 */
public static String jsonUnquote(String value) throws Exception {
return value == null ? null : new ObjectMapper().readTree(value).asText();
}
}
@@ -9,13 +9,10 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -43,9 +40,6 @@ import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class FinancePhase1OperationBehaviorTest {
@@ -87,20 +81,18 @@ class FinancePhase1OperationBehaviorTest {
assertThat(response.page().total()).isEqualTo(1);
}
/** 无效客户在进入持久化查询前拒绝。 */
@Test
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper());
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
}
/** 保留客户账户汇总的固定分页与金额口径。 */
@Test
void accountListReturnsFixedPagedDto() {
AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
@@ -120,11 +112,10 @@ class FinancePhase1OperationBehaviorTest {
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
}
/** 所有派生流水查询保留目标客户范围。 */
@Test
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
@@ -137,24 +128,58 @@ class FinancePhase1OperationBehaviorTest {
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
}
/** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */
@Test
void feeSourceListUsesFormalFieldsAndAllActiveFilters() {
var reads = mock(FinanceFeeSourceReadRepository.class);
var filters = java.util.Map.<String, Object>of("query", "current");
when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false,
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters);
when(reads.list(filters)).thenReturn(List.of());
when(reads.count(filters)).thenReturn(3L);
var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads);
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
.thenReturn(1L);
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(9001L);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(19L);
when(rs.getLong("inbound_id")).thenReturn(29L);
when(rs.getLong("container_id")).thenReturn(39L);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("source_no")).thenReturn("IN-9");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
});
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
assertThat(result.page().total()).isEqualTo(3);
assertThat(result.page().page()).isEqualTo(2);
verify(reads).list(filters);
assertThat(result.page()).satisfies(meta -> {
assertThat(meta.page()).isEqualTo(2);
assertThat(meta.pageSize()).isEqualTo(50);
assertThat(meta.total()).isEqualTo(1);
});
assertThat(result.data()).singleElement().satisfies(source -> {
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
assertThat(source.inboundCode()).isEqualTo("IN-9");
assertThat(source.containerNo()).isEqualTo("CONT-9");
assertThat(source.supplierId()).isEqualTo(79L);
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
assertThat(source.statementGenerated()).isFalse();
});
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
}
/** 流水返回业务单号和操作者快照。 */
@Test
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -172,7 +197,7 @@ class FinancePhase1OperationBehaviorTest {
});
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
@@ -186,20 +211,18 @@ class FinancePhase1OperationBehaviorTest {
});
}
/** 保留费用审核状态读取。 */
@Test
void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
}
/** 费用详情保持原客户与来源版本。 */
@Test
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
FinancePhase1OperationService service = new FinancePhase1OperationService(
feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
feeSourceJdbc("APPROVED", true, 2), objectMapper());
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
@@ -209,11 +232,10 @@ class FinancePhase1OperationBehaviorTest {
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
}
/** 业务来源费用必须回到所属系统调整。 */
@Test
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1);
@@ -225,7 +247,6 @@ class FinancePhase1OperationBehaviorTest {
(Object[]) any());
}
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
@Test
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
@@ -236,7 +257,7 @@ class FinancePhase1OperationBehaviorTest {
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
FinancePhase1OperationService service = new FinancePhase1OperationService(
jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
jdbc, objectMapper(), firstleg, "internal-token");
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
@@ -257,7 +278,6 @@ class FinancePhase1OperationBehaviorTest {
(Object[]) any());
}
/** 审核以当前操作版本更新并追加同一记录的审计。 */
@Test
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -269,6 +289,7 @@ class FinancePhase1OperationBehaviorTest {
return List.of(mapper.mapRow(resultSet(review, false, version), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
@@ -280,7 +301,7 @@ class FinancePhase1OperationBehaviorTest {
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
@@ -290,51 +311,6 @@ class FinancePhase1OperationBehaviorTest {
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
}
/** 财务内部应收应付调整原费用记录,重复提交不能新增费用或重复记审计。 */
@ParameterizedTest
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
void manualAdjustmentUpdatesOriginalFeeOnceAndChecksVersionAndFrozenState(String direction) throws Exception {
var jdbc = mock(JdbcTemplate.class);
var service = spy(new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)));
var beforeJson = objectMapper().createObjectNode().put("feeSourceId", 88).put("sourceSystem", "FINANCE")
.put("sourceType", "MANUAL").put("sourceFeeId", 700).put("sourceVersion", 1).put("customerId", 9)
.put("direction", direction).put("amount", 100).put("currency", "CNY").put("operationVersion", 2)
.put("reviewStatus", "APPROVED").put("sourceStatus", "AVAILABLE").put("statementGenerated", false);
var before = objectMapper().treeToValue(beforeJson, FeeSourceDetail.class);
var after = objectMapper().treeToValue(beforeJson.deepCopy().put("amount", 120)
.put("operationVersion", 3).put("reviewStatus", "DRAFT"), FeeSourceDetail.class);
doReturn(before, after).when(service).getFinanceFeeSource(88L);
lenient().when(jdbc.update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED")))
.thenReturn(1, 0);
var request = new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "调整金额", 2);
var result = service.editFinanceFeeSource(88, 71, "adjust-once", request, true);
assertThat(result.feeSourceId()).isEqualTo(88);
assertThat(result.sourceFeeId()).isEqualTo(700);
assertThat(result.direction()).isEqualTo(direction);
assertThat(result.amount()).isEqualByComparingTo("120");
assertThat(result.operationVersion()).isEqualTo(3);
var hash = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
eq("FEE_SOURCE_ADJUST"), eq("adjust-once"), hash.capture());
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_ADJUST"), eq("adjust-once")))
.thenReturn(List.of(java.util.Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L)));
assertThat(service.editFinanceFeeSource(88, 71, "adjust-once", request, true)).isEqualTo(after);
verify(jdbc, times(1)).update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED"));
verify(jdbc, times(1)).update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), eq(88L),
eq("FEE_SOURCE_ADJUST"), eq("调整金额"), anyString(), anyString(), eq(71L), anyString(), any(), any());
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "adjust-once",
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("121.00"), "调整金额", 2), true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Idempotency-Key已用于其他请求");
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "stale-version", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("状态或版本");
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("statementGenerated", true), FeeSourceDetail.class))
.when(service).getFinanceFeeSource(88L);
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
}
/** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@@ -342,6 +318,7 @@ class FinancePhase1OperationBehaviorTest {
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
return jdbc;
+2 -2
View File
@@ -79,8 +79,8 @@
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港为可选计划,不作为放舱必填项;填写时不得早于预计开船时间。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@@ -56,34 +55,28 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
int claim(@Param("id") long id, @Param("worker") String worker,
@Param("leaseUntil") LocalDateTime leaseUntil);
/** 只有尚未过期的当前所有者可以续租,过期执行者必须重新领取。 */
@Update("""
UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
AND lease_until>CURRENT_TIMESTAMP(3)
""")
int renew(@Param("id") long id, @Param("worker") String worker,
@Param("leaseUntil") LocalDateTime leaseUntil);
/** 完成落库必须仍持有有效租约,拒绝过期或已易主的旧执行者。 */
@Update("""
UPDATE fl_loading_completion_command
SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL,
failure_request_id=NULL,failure_trace_id=NULL
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
AND lease_until>CURRENT_TIMESTAMP(3)
""")
int complete(@Param("id") long id, @Param("worker") String worker);
/** 失败回执也受当前有效租约保护,不能覆盖新执行者的状态。 */
@Update("""
UPDATE fl_loading_completion_command
SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code},
failure_request_id=#{requestId},failure_trace_id=#{traceId}
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
AND lease_until>CURRENT_TIMESTAMP(3)
""")
int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
@@ -104,32 +97,13 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
/** 检查点事务锁住当前有效租约,与并发重新领取互斥。 */
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
@Select("""
SELECT id FROM fl_loading_completion_command
WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker}
AND lease_until>CURRENT_TIMESTAMP(3)
FOR UPDATE
""")
Long lockCheckpointLease(@Param("commandId") long commandId, @Param("worker") String worker);
/** 在已锁定的命令行上重验有效租约;检查点只能前进,不能被迟到写入降级。 */
@Insert("""
INSERT INTO fl_loading_completion_checkpoint
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
SELECT id,#{tenantId},#{inboundId},#{sequence},#{stage}
FROM fl_loading_completion_command
WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker}
AND lease_until>CURRENT_TIMESTAMP(3)
ON DUPLICATE KEY UPDATE checkpoint_stage=CASE
WHEN checkpoint_stage='INBOUND_COMPLETED' OR #{stage}='INBOUND_COMPLETED'
THEN 'INBOUND_COMPLETED'
WHEN checkpoint_stage='SETTLED' OR #{stage}='SETTLED' THEN 'SETTLED'
ELSE 'READY' END,updated_at=CURRENT_TIMESTAMP(3)
VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage})
ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3)
""")
int checkpointCompleted(@Param("commandId") long commandId, @Param("worker") String worker,
@Param("tenantId") long tenantId,
int checkpointCompleted(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("sequence") int sequence,
@Param("stage") String stage);
}
@@ -126,17 +126,6 @@ public class PlatformOceanBookingController {
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
@PutMapping("/{oceanBookingId}/actual-cargo")
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ACTUAL_CARGO", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="补录订舱实际货量")
public ApiResponse<Detail> updateActualCargo(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActualCargoRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateActualCargo(oceanBookingId, user.userId(), user.username(), key, request));
}
@DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')")
@BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿")
public ApiResponse<Void> deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); }
@@ -45,12 +45,10 @@ public final class OceanBookingDtos {
String customerName, String warehouseName, String channelName) {}
public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
@NotNull @Size(max = 255) String partyName,
@NotBlank @Size(max = 255) String partyName,
@Size(max = 1000) String address,
@Size(max = 128) String contactName,
@Size(max = 64) String contactPhone,
@jakarta.validation.constraints.Email @Size(max = 255) String contactEmail,
@Size(max = 128) String taxNo) {}
@Size(max = 64) String contactPhone) {}
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind,
@@ -78,31 +76,7 @@ public final class OceanBookingDtos {
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@Valid @Size(max = 3) List<PartyRequest> parties,
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements,
@Size(max = 64) String freightTerm,
@Size(max = 64) String serviceTerm,
@Pattern(regexp = "CARTONS|PCS|PALLETS") String packageUnit,
@Size(max = 1000) String cargoDescription,
@Size(max = 1000) String marksAndNos,
@Min(1) @Max(999999999) Integer declaredPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredVolumeCbm,
@Min(1) @Max(999999999) Integer actualPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
/** 放舱后的受控实货量补录,不接收运输资料。 */
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
@Min(1) @Max(999999999) Integer actualPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm,
@NotBlank @Size(max = 1000) String reason) {}
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
@@ -40,17 +40,6 @@ public class OceanBookingEntity {
private LocalDateTime estimatedSailingAt;
private LocalDateTime estimatedArrivalAt;
private String remark;
private String freightTerm;
private String serviceTerm;
private String packageUnit;
private String cargoDescription;
private String marksAndNos;
private Integer declaredPackageCount;
private java.math.BigDecimal declaredGrossWeightKg;
private java.math.BigDecimal declaredVolumeCbm;
private Integer actualPackageCount;
private java.math.BigDecimal actualGrossWeightKg;
private java.math.BigDecimal actualVolumeCbm;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
@@ -17,6 +17,4 @@ public class OceanBookingPartyEntity {
private String address;
private String contactName;
private String contactPhone;
private String contactEmail;
private String taxNo;
}
@@ -25,17 +25,8 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
""")
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
@Update("""
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND status='RELEASED' AND version=#{version}
""")
int updateActualCargo(@Param("value") OceanBookingEntity value, @Param("version") int version,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int deleteDraft(@Param("id") long id, @Param("version") int version);
@Select("""
@@ -54,10 +54,6 @@ public class OceanBookingRepository {
public int touchDraft(long id, int version, long operatorId, String operatorName) {
return bookings.touchDraft(id, version, operatorId, operatorName);
}
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateActualCargo(value, version, operatorId, operatorName);
}
public int deleteDraft(long id, int version) {
return bookings.deleteDraft(id, version);
}
@@ -46,7 +46,7 @@ public class OceanBookingPrintService {
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
fields(template, detail), List.of(cargo(template, booking)))));
fields(template, detail), List.of(emptyCargo()))));
try {
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType();
@@ -75,21 +75,14 @@ public class OceanBookingPrintService {
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
fields.put("serviceTerm", text(booking.getServiceTerm()));
fields.put("freightTerm", text(booking.getFreightTerm()));
Integer count = SHIPPING_INSTRUCTION.equals(template) ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
fields.put("totalPackagesInWords", packageWords(count, booking.getPackageUnit()));
fields.put("serviceTerm", "");
fields.put("freightTerm", "");
fields.put("totalPackagesInWords", "");
if (SHIPPING_INSTRUCTION.equals(template)) {
fields.put("toCompany", text(booking.getBookingAgentName()));
fields.put("jobNumber", text(booking.getBookingNo()));
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
fields.put("cargoQuantity", detail.containerRequirements().stream()
.map(value -> value.getQuantity() + "*" + value.getContainerType()).collect(Collectors.joining(" + ")));
OceanBookingPartyEntity shipper = parties.get("SHIPPER");
fields.put("picName", shipper == null ? "" : text(shipper.getContactName()));
fields.put("picTel", shipper == null ? "" : text(shipper.getContactPhone()));
fields.put("apName", text(booking.getBookingAgentName()));
fields.put("printDate", java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).toString());
fields.put("cargoQuantity", "");
fields.put("carrier", text(booking.getShippingCompanyName()));
fields.put("bookingAgent", text(booking.getBookingAgentName()));
fields.put("remark", text(booking.getRemark()));
@@ -105,43 +98,8 @@ public class OceanBookingPrintService {
return fields;
}
/** 两类单据严格分别读取委、实数据,不以柜型容量或预报量代替实装量。 */
private static PrintPdfClient.CargoRow cargo(String template, OceanBookingEntity booking) {
boolean declared = SHIPPING_INSTRUCTION.equals(template);
Integer count = declared ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
BigDecimal weight = declared ? booking.getDeclaredGrossWeightKg() : booking.getActualGrossWeightKg();
BigDecimal volume = declared ? booking.getDeclaredVolumeCbm() : booking.getActualVolumeCbm();
return new PrintPdfClient.CargoRow(display(booking.getMarksAndNos()), count == null ? "—" : count.toString(),
display(booking.getPackageUnit()), display(booking.getCargoDescription()),
weight == null ? "—" : weight.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " KGS",
volume == null ? "—" : volume.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " CBM");
}
/** 打印空字段使用统一占位符。 */
private static String display(String value) { return text(value).isEmpty() ? "—" : text(value); }
/** 件数大写跟随所选数据和包装单位生成,未录入时明确留空。 */
private static String packageWords(Integer count, String unit) {
if (count == null || !org.springframework.util.StringUtils.hasText(unit)) return "";
String packaging = count == 1 ? switch (unit) {
case "CARTONS" -> "CARTON";
case "PALLETS" -> "PALLET";
case "PCS" -> "PIECE";
default -> unit;
} : ("PCS".equals(unit) ? "PIECES" : unit);
return "SAY " + words(count) + " " + packaging + " ONLY";
}
/** 将受件数范围约束的正整数转换为英文数量。 */
private static String words(int value) {
String[] small = {"ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN",
"ELEVEN", "TWELVE", "THIRTEEN", "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN", "EIGHTEEN", "NINETEEN"};
String[] tens = {"", "", "TWENTY", "THIRTY", "FORTY", "FIFTY", "SIXTY", "SEVENTY", "EIGHTY", "NINETY"};
if (value < 20) return small[value];
if (value < 100) return tens[value / 10] + (value % 10 == 0 ? "" : " " + words(value % 10));
int divisor = value >= 1000000 ? 1000000 : value >= 1000 ? 1000 : 100;
String scale = divisor == 1000000 ? "MILLION" : divisor == 1000 ? "THOUSAND" : "HUNDRED";
return words(value / divisor) + " " + scale + (value % divisor == 0 ? "" : " " + words(value % divisor));
private static PrintPdfClient.CargoRow emptyCargo() {
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
}
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
@@ -152,8 +110,7 @@ public class OceanBookingPrintService {
private static String party(OceanBookingPartyEntity value) {
if (value == null) return "";
return List.of(text(value.getPartyName()), text(value.getAddress()),
join(value.getContactName(), value.getContactPhone()), text(value.getContactEmail()),
text(value.getTaxNo()).isEmpty() ? "" : "TAX NO: " + text(value.getTaxNo())).stream()
join(value.getContactName(), value.getContactPhone())).stream()
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
}
@@ -145,26 +145,6 @@ public class OceanBookingService {
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after));
}
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
@Transactional
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
String action = "UPDATE_ACTUAL_CARGO";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.RELEASED);
if (!StringUtils.hasText(request.reason())) throw badRequest("请填写实货量补录或修正原因");
validateCargo(request.actualPackageCount(), request.actualGrossWeightKg(), request.actualVolumeCbm());
OceanBookingEntity after = objectMapper.convertValue(before, OceanBookingEntity.class);
after.setActualPackageCount(request.actualPackageCount());
after.setActualGrossWeightKg(request.actualGrossWeightKg());
after.setActualVolumeCbm(request.actualVolumeCbm());
if (repository.updateActualCargo(after, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity saved = required(id);
addHistory(id, before.getStatus(), saved.getStatus(), action, request.reason(), before, saved, operatorId, name);
return complete(id, operatorId, action, key, detail(saved));
}
@Transactional
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "DELETE_DRAFT";
@@ -229,18 +209,7 @@ public class OceanBookingService {
private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
/** 未采集使用空值;已填写的件数、重量和体积必须为正数且不超出存储精度。 */
private static void validateCargo(Integer count, java.math.BigDecimal weight, java.math.BigDecimal volume) {
if (count != null && (count < 1 || count > 999999999)) throw badRequest("货物件数必须为1到999999999的整数");
for (java.math.BigDecimal number : new java.math.BigDecimal[] {weight, volume}) {
if (number != null && (number.signum() <= 0 || number.scale() > 3
|| number.precision() - number.scale() > 12)) throw badRequest("货物重量和体积必须为正数,最多12位整数和3位小数");
}
}
private void validateDraftStructure(DraftData data) {
validateCargo(data.declaredPackageCount(), data.declaredGrossWeightKg(), data.declaredVolumeCbm());
validateCargo(data.actualPackageCount(), data.actualGrossWeightKg(), data.actualVolumeCbm());
boolean hasCustomerId = data.customerId() != null;
boolean hasCustomerName = StringUtils.hasText(data.customerName());
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
@@ -262,7 +231,6 @@ public class OceanBookingService {
}
}
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
/** 放舱要求预计到港,供关联柜次的到港日历及默认到仓日期使用。 */
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> requirements) {
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
@@ -274,7 +242,6 @@ public class OceanBookingService {
|| !StringUtils.hasText(booking.getPlaceOfDelivery())
|| !StringUtils.hasText(booking.getCarrierCode())
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|| (parties != null && parties.stream().anyMatch(party -> !StringUtils.hasText(party.getPartyName())))
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
}
@@ -331,17 +298,6 @@ public class OceanBookingService {
v.setPlaceOfDelivery(trim(d.placeOfDelivery()));
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
v.setRemark(trim(d.remark()));
v.setFreightTerm(trim(d.freightTerm()));
v.setServiceTerm(trim(d.serviceTerm()));
v.setPackageUnit(trim(d.packageUnit()));
v.setCargoDescription(trim(d.cargoDescription()));
v.setMarksAndNos(trim(d.marksAndNos()));
v.setDeclaredPackageCount(d.declaredPackageCount());
v.setDeclaredGrossWeightKg(d.declaredGrossWeightKg());
v.setDeclaredVolumeCbm(d.declaredVolumeCbm());
v.setActualPackageCount(d.actualPackageCount());
v.setActualGrossWeightKg(d.actualGrossWeightKg());
v.setActualVolumeCbm(d.actualVolumeCbm());
}
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
@@ -414,7 +370,7 @@ public class OceanBookingService {
return normalized;
}
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); v.setContactEmail(trim(r.contactEmail())); v.setTaxNo(trim(r.taxNo())); return v; }).toList(); }
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
@@ -11,27 +11,16 @@ import java.util.stream.Collectors;
@Service
public class AppointmentForecastService {
public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest,
AppointmentForecastMapper.Fact previous,String timeBasis,
java.time.LocalDateTime portArrivalAt) { }
AppointmentForecastMapper.Fact previous,String timeBasis) { }
private final AppointmentForecastMapper mapper;
private final com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources;
/** 读取船节点及日历权威到港来源,不从调用端接收推算日期。 */
public AppointmentForecastService(AppointmentForecastMapper mapper,
com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources) {
this.mapper=mapper; this.sources=sources;
}
/** 返回明确来源柜的到港事实,保留未知日期。 */
public AppointmentForecastService(AppointmentForecastMapper mapper){this.mapper=mapper;}
public List<Forecast> batch(List<Long> ids) {
if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0)
|| new HashSet<>(ids).size()!=ids.size()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每批须提供1至200个不重复的柜次");
var facts=mapper.latest(ids).stream().collect(Collectors.groupingBy(AppointmentForecastMapper.Fact::containerBookingId));
var arrivals=sources.byIds(ids).stream().collect(Collectors.toMap(
com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource::containerBookingId,
java.util.function.Function.identity()));
return ids.stream().map(id->{
var rows=facts.getOrDefault(id,List.of());
return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED",
arrivals.containsKey(id)?arrivals.get(id).arrivalAt():null);
return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED");
}).toList();
}
}
@@ -4,7 +4,6 @@ import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@@ -13,9 +12,6 @@ import java.util.List;
public class LoadingCompletionCommandRepository {
@Resource private LoadingCompletionCommandMapper mapper;
/** 按事务提交后的命令标识读取唯一完成请求。 */
public LoadingCompletionCommandEntity command(long id) { return mapper.selectById(id); }
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
@@ -32,13 +28,7 @@ public class LoadingCompletionCommandRepository {
public String checkpoint(long commandId, long tenantId, long inboundId) {
return mapper.checkpoint(commandId, tenantId, inboundId);
}
/** 命令行锁覆盖租约检查与检查点写入,旧执行者不能在租约易主后覆盖新结果。 */
@Transactional
public boolean checkpoint(long commandId, String worker, long tenantId, long inboundId,
int sequence, String stage) {
if (mapper.lockCheckpointLease(commandId, worker) == null) return false;
int written = mapper.checkpointCompleted(commandId, worker, tenantId, inboundId, sequence, stage);
// 同阶段重放可能被驱动计为零行;命令行仍被本事务锁住,重验租约区分幂等与过期拒绝。
return written > 0 || mapper.lockCheckpointLease(commandId, worker) != null;
public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) {
mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage);
}
}
@@ -9,77 +9,37 @@ import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
/** 事务提交后立即处理 PDA 完成命令,并按持久化租约定时恢复。 */
/** Lease-based recovery worker for PDA completion commands. */
@Service
public class LoadingCompletionWorker {
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
private static final int MAX_ATTEMPTS = 5;
private final String workerId = "pda-completion-" + UUID.randomUUID();
@Resource private LoadingCompletionCommandRepository commands;
@Resource private LoadingPickRepository picks;
@Resource private LoadingPickService loadingPicks;
@Resource(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
/** 唤醒只发生在受理事务提交后,异步执行不延长 HTTP 受理请求。 */
@TransactionalEventListener
public void accepted(LoadingPickService.CompletionAccepted accepted) {
try {
completionExecutor.execute(() -> dispatchAccepted(accepted));
} catch (RejectedExecutionException exception) {
// 执行器繁忙或关闭时保留持久化命令,仍由定时扫描领取,不能影响已提交回执。
log.warn("pda_completion_wakeup_deferred commandId={}", accepted.commandId(), exception);
}
}
/** 唤醒与恢复共用原子领取;同一命令不会被两个执行入口重复结算。 */
private void dispatchAccepted(LoadingPickService.CompletionAccepted accepted) {
try {
LoadingCompletionCommandEntity command = commands.command(accepted.commandId());
if (command != null) process(command, "immediate", accepted.acceptedAtNanos());
} catch (RuntimeException exception) {
log.warn("pda_completion_wakeup_failed commandId={}", accepted.commandId(), exception);
}
}
/** 扫描到期或租约过期命令,覆盖唤醒丢失、进程重启及失败退避恢复。 */
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
public void dispatch() {
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
process(candidate, "scheduled", System.nanoTime());
if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) {
continue;
}
try {
loadingPicks.processCompletion(candidate, workerId);
} catch (RuntimeException exception) {
handleFailure(candidate, exception);
}
}
}
/** 保留租约、检查点和失败恢复语义,记录领取及实际处理耗时。 */
private void process(LoadingCompletionCommandEntity command, String source, long queuedAt) {
// 每次领取使用独立所有者,避免旧执行线程在租约过期、同进程重新领取后继续写入。
String leaseOwner = "pda-completion-" + UUID.randomUUID();
if (!commands.claim(command.getId(), leaseOwner, LocalDateTime.now().plusMinutes(10))) return;
long started = System.nanoTime();
boolean succeeded = false;
log.info("pda_completion_started commandId={} taskId={} source={} wakeupMs={}",
command.getId(), command.getPickTaskId(), source, (started - queuedAt) / 1_000_000);
try {
loadingPicks.processCompletion(command, leaseOwner);
succeeded = true;
} catch (RuntimeException exception) {
handleFailure(command, leaseOwner, exception);
} finally {
log.info("pda_completion_finished commandId={} taskId={} source={} durationMs={} succeeded={}",
command.getId(), command.getPickTaskId(), source, (System.nanoTime() - started) / 1_000_000,
succeeded);
}
}
/** 确定性失败关闭任务,其余故障按原退避与最大次数恢复。 */
private void handleFailure(LoadingCompletionCommandEntity command, String leaseOwner, RuntimeException exception) {
private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) {
Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException
&& exception.getCause() != null ? exception.getCause() : exception;
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
@@ -93,7 +53,7 @@ public class LoadingCompletionWorker {
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS;
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
boolean recorded = commands.fail(command.getId(), leaseOwner, terminal ? "FAILED" : "RETRYABLE", retryAt,
boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt,
stage, code, requestId, traceId);
if (terminal && recorded) {
picks.failCompletion(command.getPickTaskId());
@@ -103,7 +63,6 @@ public class LoadingCompletionWorker {
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
}
/** 仅将已明确不可重试的本地业务门禁判为终态。 */
private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) {
if (!(cause instanceof BizException business)) {
return false;
@@ -56,7 +56,6 @@ import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -88,8 +87,6 @@ public class LoadingPickService {
@Resource
private LoadingCompletionCommandRepository completionCommands;
@Resource
private ApplicationEventPublisher completionEvents;
@Resource
private TransactionTemplate completionTransaction;
@Resource
private SmartLoadingRepository smartLoadingRepository;
@@ -555,7 +552,6 @@ public class LoadingPickService {
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
throw conflict("装柜完成命令状态已变化,请刷新后重试");
}
completionEvents.publishEvent(new CompletionAccepted(replay.getId(), System.nanoTime()));
}
return view(requiredTask(taskId), null);
}
@@ -602,14 +598,9 @@ public class LoadingPickService {
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
// 提交后立即唤醒仍经持久化租约领取;回滚不得触发结算,定时扫描继续负责故障恢复。
completionEvents.publishEvent(new CompletionAccepted(command.getId(), System.nanoTime()));
return view(requiredTask(taskId), null);
}
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
private void requireCompletionContainerNumber(long taskId) {
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
@@ -622,7 +613,7 @@ public class LoadingPickService {
}
}
/** 仅在持久化租约领取成功后执行,远程结算不占用受理事务。 */
/** Called only by the completion worker, after its durable lease has committed. */
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
long taskId = command.getPickTaskId();
inboundHoldGuard.requireTaskNotHeld(taskId);
@@ -634,55 +625,55 @@ public class LoadingPickService {
.toList();
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
List<GroupSnapshot> taskGroups = coloadGroups(owners);
runCompletionStage(taskId, "CABINET_EVIDENCE", () ->
runCompletionStage("CABINET_EVIDENCE", () ->
cabinetEvidenceService.requireComplete(task.getContainerBookingId()));
runCompletionStage(taskId, "COMPLETED_EVENT_READY", () ->
runCompletionStage("COMPLETED_EVENT_READY", () ->
loadingListService.requireCompletedEventReady(task.getLoadingPlanId()));
// 全柜所有客户先通过只读门禁,再开始资源结算,避免后续客户失败造成前序客户局部完成。
// Phase 1 is read-only: cabinet evidence is checked once, then every owner must pass its business gates
// before any warehouse resource or inbound status can be changed. This prevents a later customer gate from
// leaving earlier customers partially completed.
for (int index = 0; index < owners.size(); index++) {
requireCompletionLease(command.getId(), worker);
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage(taskId, "OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
owner.tenantId(), owner.inboundId()));
if (!checkpointAtLeast(checkpoint, "READY")) {
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(),
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(),
index + 1, "READY");
}
}
}
// 全柜门禁通过后,按客户检查点和稳定幂等键逐步结算,已完成的检查点可安全续做。
// Phase 2 is recoverable by per-owner checkpoints and stable idempotency keys. It starts only after the
// complete cabinet passed phase 1, while prior deployments' completed checkpoints remain safely resumable.
for (int index = 0; index < owners.size(); index++) {
requireCompletionLease(command.getId(), worker);
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage(taskId, "WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
}
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
runCompletionStage(taskId, "INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
owner.tenantId(), owner.inboundId(),
command.getOperatorId(), command.getOperatorName(),
"PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1,
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
"INBOUND_COMPLETED");
}
}
long transactionStarted = System.nanoTime();
try {
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
} finally {
log.info("pda_completion_transaction taskId={} durationMs={}", taskId,
(System.nanoTime() - transactionStarted) / 1_000_000);
}
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
}
/** 远程结算完成后,仅在短事务内收敛 Firstleg 本地完成事实。 */
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
long taskId = task.getId();
@@ -697,11 +688,11 @@ public class LoadingPickService {
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
command.getOperatorId(), command.getOperatorName());
}
runCompletionStage(taskId, "PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
command.getOperatorId(), command.getOperatorName()));
runCompletionStage(taskId, "COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
for (GroupSnapshot group : taskGroups) {
runCompletionStage(taskId, "COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|'
+ group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName()));
}
@@ -759,14 +750,6 @@ public class LoadingPickService {
}
}
/** 检查点失去当前有效租约时立即停止,不再推进后续客户或柜次。 */
private void recordCompletionCheckpoint(long commandId, String worker, long tenantId, long inboundId,
int sequence, String stage) {
if (!completionCommands.checkpoint(commandId, worker, tenantId, inboundId, sequence, stage)) {
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
}
}
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
String requestHash, long operatorId) {
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
@@ -781,18 +764,11 @@ public class LoadingPickService {
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
}
/** 记录实际阶段耗时,区分业务处理与调度、前端刷新等待。 */
private static void runCompletionStage(long taskId, String stage, Runnable action) {
long started = System.nanoTime();
boolean succeeded = false;
private static void runCompletionStage(String stage, Runnable action) {
try {
action.run();
succeeded = true;
} catch (RuntimeException exception) {
throw new CompletionProcessingException(stage, exception);
} finally {
log.info("pda_completion_stage taskId={} stage={} durationMs={} succeeded={}",
taskId, stage, (System.nanoTime() - started) / 1_000_000, succeeded);
}
}
@@ -9,25 +9,6 @@ import java.util.List;
@Mapper
public interface ContainerCalendarSourceMapper {
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
@Select("""
<script>
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
FROM fl_container_booking c
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
WHERE c.record_status='ACTIVE' AND c.id IN
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
</script>
""")
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
@Select("""
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
@@ -11,8 +11,6 @@ public class ContainerCalendarSourceRepository {
private final ContainerCalendarSourceMapper mapper;
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
/** 内部批量读取明确柜次,日期口径与平台日历一致。 */
public List<ContainerCalendarSource> byIds(List<Long> ids) { return mapper.byIds(ids); }
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
@@ -27,7 +27,7 @@ public class WorkflowTabService {
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(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) {
try {
@@ -61,11 +61,11 @@ public class WorkflowTabService {
Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
boolean signed="DELIVERED".equals(process);
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
boolean clearanceStage=f.departed();
// 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。
boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded();
if(!clearanceStage && !signed) {
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
if(f.exportCustomsReady() || f.customsCompleted()) keys.add("EXPORT_CUSTOMS");
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
}
if(signed) keys.add("SIGNED");
if(clearanceStage && !signed) {
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照',
@@ -27,17 +27,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
freight_term VARCHAR(64) NULL COMMENT '运费条款',
service_term VARCHAR(64) NULL COMMENT '运输条款',
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
@@ -55,14 +44,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_carrier_code CHECK
(carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_ocean_booking_cargo CHECK (
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
@@ -129,8 +110,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
address VARCHAR(1000) NULL COMMENT '关系方地址快照',
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
@@ -232,47 +211,8 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
IF OLD.status = 'RELEASED' AND NOT (
(NEW.id <=> OLD.id)
AND (NEW.booking_no <=> OLD.booking_no)
AND (NEW.status <=> OLD.status)
AND (NEW.customer_id <=> OLD.customer_id)
AND (NEW.customer_name <=> OLD.customer_name)
AND (NEW.channel_code <=> OLD.channel_code)
AND (NEW.channel_name <=> OLD.channel_name)
AND (NEW.warehouse_id <=> OLD.warehouse_id)
AND (NEW.warehouse_name <=> OLD.warehouse_name)
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
AND (NEW.carrier_code <=> OLD.carrier_code)
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
AND (NEW.vessel_name <=> OLD.vessel_name)
AND (NEW.voyage_no <=> OLD.voyage_no)
AND (NEW.departure_port_code <=> OLD.departure_port_code)
AND (NEW.departure_port_name <=> OLD.departure_port_name)
AND (NEW.destination_port_code <=> OLD.destination_port_code)
AND (NEW.destination_port_name <=> OLD.destination_port_name)
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
AND (NEW.remark <=> OLD.remark)
AND (NEW.freight_term <=> OLD.freight_term)
AND (NEW.service_term <=> OLD.service_term)
AND (NEW.package_unit <=> OLD.package_unit)
AND (NEW.cargo_description <=> OLD.cargo_description)
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
AND (NEW.declared_package_count <=> OLD.declared_package_count)
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
AND (NEW.created_by <=> OLD.created_by)
AND (NEW.created_by_name <=> OLD.created_by_name)
AND (NEW.created_at <=> OLD.created_at)
AND NEW.version = OLD.version + 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -34,7 +34,6 @@ paths:
properties:
containerBookingId: {type: string}
timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]}
portArrivalAt: {type: string, format: date-time, nullable: true, description: 与预约日历相同的权威到港当地时间,最新有效实际到港优先,其次柜资料实际到港及预计到港;未知保持空}
latest: {$ref: '#/components/schemas/Fact'}
previous: {$ref: '#/components/schemas/Fact'}
'401': {description: 内部身份缺失、错误或服务端未配置}
@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: Ocean Booking API
version: 2.2.0
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
version: 2.1.0
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released bookings and their file links are permanently read-only.
paths:
/api/platform/v1/ocean-bookings:
get:
@@ -29,14 +29,6 @@ paths:
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: Deleted}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
put:
summary: Supplement manual actual cargo on a RELEASED booking
description: Only actual package count, gross weight and volume may change. Transport and declared cargo remain frozen. Requires expectedVersion, Idempotency-Key and a reason; saves before/after audit snapshots atomically. Null means not recorded, never zero or a fallback to declared cargo.
x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualCargoRequest'}}}}
responses: {'200': {description: Updated RELEASED detail}, '409': {description: State or version conflict}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post:
summary: Validate and permanently release a DRAFT
@@ -97,7 +89,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get:
summary: Render the current saved booking as an export shipping instruction PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved declared values; container requirements describe requested containers only. Count in words is generated from the declared count and packageUnit.
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -105,7 +97,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get:
summary: Render the current saved booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved manually entered actual values only; missing actual data displays — and never falls back to declared values. This is a confirmation copy; the externally issued bill of lading remains an uploaded attachment.
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -200,33 +192,12 @@ components:
destinationPortName: {type: string, nullable: true}
placeOfDelivery: {type: string, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true}
estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 草稿可留空,放舱前必填;不得早于预计开船时间,关联柜次后作为到港日历及默认到仓日期来源}
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
remark: {type: string, nullable: true}
freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID}
serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY}
packageUnit: {type: string, enum: [CARTONS, PCS, PALLETS], nullable: true}
cargoDescription: {type: string, maxLength: 1000, nullable: true}
marksAndNos: {type: string, maxLength: 1000, nullable: true}
declaredPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
declaredGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
declaredVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}}
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}}
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
ActualCargoRequest:
type: object
additionalProperties: false
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 1000}
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
@@ -340,8 +311,6 @@ components:
required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
properties:
oceanBooking:
allOf:
- {$ref: '#/components/schemas/DraftData'}
type: object
properties:
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
@@ -32,7 +32,6 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateActualCargo", "platform:firstleg:ocean-booking:edit"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
Map.entry("release", "platform:firstleg:ocean-booking:release"));
@@ -48,24 +47,6 @@ class PlatformOceanBookingControllerContractTest {
});
}
@Test
@SuppressWarnings("unchecked")
void actualCargoContractExposesOnlySupplementFieldsAndReusesEditPermission() throws Exception {
assertThat(method("updateActualCargo").getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value())
.containsExactly("/{oceanBookingId}/actual-cargo");
Map<String, Object> document = new org.yaml.snakeyaml.Yaml().load(
Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")));
var paths = (Map<String, Object>) document.get("paths");
var endpoint = (Map<String, Object>) paths.get("/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo");
var operation = (Map<String, Object>) endpoint.get("put");
assertThat(operation).containsEntry("x-permission", "platform:firstleg:ocean-booking:edit");
var schemas = (Map<String, Object>) ((Map<String, Object>) document.get("components")).get("schemas");
var request = (Map<String, Object>) schemas.get("ActualCargoRequest");
assertThat((Map<String, Object>) request.get("properties")).containsOnlyKeys(
"expectedVersion", "reason", "actualPackageCount", "actualGrossWeightKg", "actualVolumeCbm");
assertThat((java.util.List<String>) request.get("required")).contains("expectedVersion", "reason");
}
@Test
void pathVariablesUseExplicitAggregateNames() {
assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
@@ -46,16 +46,6 @@ class OceanBookingMapperContractTest {
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
}
@Test
void releasedSupplementSqlCannotWriteTransportOrDeclaredCargo() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateActualCargo",
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity.class, int.class, long.class, String.class)
.getAnnotation(Update.class).value());
assertThat(sql).contains("status='RELEASED' AND version=#{version}", "version=version+1",
"actual_package_count=#{value.actualPackageCount}", "actual_gross_weight_kg=#{value.actualGrossWeightKg}", "actual_volume_cbm=#{value.actualVolumeCbm}")
.doesNotContain("bill_of_lading_no=", "vessel_name=", "declared_", "warehouse_id=", "package_unit=");
}
@Test
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
@@ -27,8 +27,8 @@ class OceanBookingSqlContractTest {
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.contains("service_term", "declared_package_count", "actual_package_count")
.contains("freight_term", "contact_email", "tax_no")
.doesNotContain("service_term")
.doesNotContain("freight_term")
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
@@ -49,7 +49,7 @@ class OceanBookingSqlContractTest {
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
.contains("OLD.status = 'RELEASED'", "NEW.declared_package_count <=> OLD.declared_package_count", "NEW.bill_of_lading_no <=> OLD.bill_of_lading_no", "NEW.version = OLD.version + 1")
.contains("OLD.status = 'RELEASED'")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("<> 'RELEASED' THEN")
@@ -49,7 +49,7 @@ class OceanBookingPrintServiceTest {
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("oceanVesselVoyage", "Vessel / V001")
.containsEntry("cargoQuantity", "2*40HQ")
.containsEntry("cargoQuantity", "—")
.containsEntry("totalPackagesInWords", "—")
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
@@ -95,39 +95,6 @@ class OceanBookingPrintServiceTest {
.containsEntry("totalPackagesInWords", "—");
}
@Test void routesDeclaredAndActualCargoSeparatelyAndGeneratesWords() {
Detail detail = detail("RELEASED");
var booking = detail.oceanBooking();
booking.setPackageUnit("CARTONS"); booking.setDeclaredPackageCount(500); booking.setActualPackageCount(480);
booking.setDeclaredGrossWeightKg(new BigDecimal("15000")); booking.setDeclaredVolumeCbm(new BigDecimal("68"));
booking.setActualGrossWeightKg(new BigDecimal("14320.125")); booking.setActualVolumeCbm(new BigDecimal("65.321"));
booking.setCargoDescription("HOUSEHOLD GOODS"); booking.setMarksAndNos("N/M");
booking.setServiceTerm("CY-CY"); booking.setFreightTerm("FREIGHT PREPAID");
detail.parties().get(0).setContactEmail("ops@example.test"); detail.parties().get(0).setTaxNo("TEST-TAX");
when(bookings.detail(10L)).thenReturn(detail);
when(print.generateFreightA4Documents(any(), any(), any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1}));
service.shippingInstruction(10L, "split"); service.billConfirmation(10L, "split");
var capture = ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
verify(print, times(2)).generateFreightA4Documents(any(), any(), capture.capture());
var declared = capture.getAllValues().get(0).documents().get(0);
var actual = capture.getAllValues().get(1).documents().get(0);
assertThat(declared.cargoRows().get(0).quantity()).isEqualTo("500");
assertThat(declared.cargoRows().get(0).grossWeight()).isEqualTo("15000.000 KGS");
assertThat(declared.fields()).containsEntry("totalPackagesInWords", "SAY FIVE HUNDRED CARTONS ONLY")
.containsEntry("serviceTerm", "CY-CY").containsEntry("freightTerm", "FREIGHT PREPAID");
assertThat(declared.fields().get("shipper")).contains("ops@example.test", "TAX NO: TEST-TAX");
assertThat(actual.cargoRows().get(0).quantity()).isEqualTo("480");
assertThat(actual.cargoRows().get(0).grossWeight()).isEqualTo("14320.125 KGS");
assertThat(actual.cargoRows().get(0).measurement()).isEqualTo("65.321 CBM");
assertThat(actual.fields()).containsEntry("totalPackagesInWords", "SAY FOUR HUNDRED EIGHTY CARTONS ONLY");
booking.setActualPackageCount(null); booking.setActualGrossWeightKg(null); booking.setActualVolumeCbm(null);
service.billConfirmation(10L, "missing");
verify(print).generateFreightA4Documents(any(), eq("missing"), capture.capture());
assertThat(capture.getValue().documents().get(0).cargoRows().get(0).quantity()).isEqualTo("—");
assertThat(capture.getValue().documents().get(0).fields()).containsEntry("totalPackagesInWords", "—");
}
private static Detail detail(String status) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
@@ -231,19 +231,6 @@ class OceanBookingServiceTest {
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
}
/** 缺预计到港不能放舱,避免关联柜次后丢失日历及默认到仓日期来源。 */
@Test
void rejectsReleaseWithoutEstimatedArrival() {
OceanBookingEntity draft = completeBooking("DRAFT", 3);
draft.setEstimatedArrivalAt(null);
when(repository.find(1L)).thenReturn(draft);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-arrival", new ReleaseRequest(3, "确认放舱")))
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void rejectsCarrierSnapshotMismatchAndUnassociatedBookingAgent() {
stubMasterData();
@@ -399,91 +386,6 @@ class OceanBookingServiceTest {
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
OceanBookingEntity before = completeBooking("RELEASED", 3);
before.setDeclaredPackageCount(500);
before.setActualPackageCount(450);
AtomicReference<OceanBookingEntity> stored = new AtomicReference<>(before);
when(repository.find(1L)).thenAnswer(invocation -> stored.get());
when(repository.updateActualCargo(any(), eq(3), eq(9L), eq("admin"))).thenAnswer(invocation -> {
OceanBookingEntity after = invocation.getArgument(0);
after.setVersion(4); stored.set(after); return 1;
});
var request = new ActualCargoRequest(3, 480, new BigDecimal("14000.125"), new BigDecimal("65.321"), "修正实装货量");
var result = service.updateActualCargo(1L, 9L, "admin", "actual-save", request);
assertThat(result.oceanBooking().getActualPackageCount()).isEqualTo(480);
assertThat(result.oceanBooking().getDeclaredPackageCount()).isEqualTo(500);
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
assertThat(before.getActualPackageCount()).isEqualTo(450);
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ACTUAL_CARGO");
assertThat(history.getValue().getBeforeSnapshot()).contains("\"actualPackageCount\":450");
assertThat(history.getValue().getAfterSnapshot()).contains("\"actualPackageCount\":480");
assertThat(history.getValue().getReason()).isEqualTo("修正实装货量");
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
verify(repository, never()).replaceRequirements(anyLong(), any());
verifyNoInteractions(documents, priceBooks, suppliers, warehouses);
}
@Test
void rejectsActualCargoOnDraftOrStaleVersionAndRequiresReason() {
OceanBookingEntity value = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(value);
var request = new ActualCargoRequest(3, 100, null, null, "补录");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-draft", request)).isInstanceOf(BizException.class);
value.setStatus("RELEASED");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-stale",
new ActualCargoRequest(2, 100, null, null, "补录"))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-no-reason",
new ActualCargoRequest(3, 100, null, null, " "))).isInstanceOf(BizException.class).hasMessageContaining("原因");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-negative",
new ActualCargoRequest(3, 100, new BigDecimal("-1"), null, "补录"))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-precision",
new ActualCargoRequest(3, 100, null, new BigDecimal("1.0001"), "补录"))).isInstanceOf(BizException.class);
when(repository.updateActualCargo(any(), anyInt(), anyLong(), any())).thenReturn(0);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-concurrent", request)).isInstanceOf(BizException.class);
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
}
@Test
void replayedActualCargoDoesNotWriteAgain() throws Exception {
var request = new ActualCargoRequest(3, 100, null, null, "补录");
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
var operation = new OceanBookingOperationEntity();
operation.setRequestHash(hash("UPDATE_ACTUAL_CARGO", 1L, request));
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
when(repository.claimOperation(1L, 9L, "UPDATE_ACTUAL_CARGO", "actual-replay", operation.getRequestHash())).thenReturn(false);
when(repository.operation(9L, "UPDATE_ACTUAL_CARGO", "actual-replay")).thenReturn(operation);
assertThat(service.updateActualCargo(1L, 9L, "admin", "actual-replay", request).oceanBooking().getVersion()).isEqualTo(4);
verify(repository, never()).updateActualCargo(any(), anyInt(), anyLong(), any());
}
@Test
void savesUnnamedPartyContactDraftButRequiresNamesBeforeRelease() throws Exception {
var tree = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.valueToTree(partialDraft(null));
tree.putArray("parties").addObject().put("partyType", "SHIPPER").put("partyName", "")
.put("contactEmail", "ops@example.test").put("taxNo", "TEST-TAX");
stubCreatedDraft();
service.create(9L, "admin", "partial-party", new CreateRequest(0, objectMapper.treeToValue(tree, DraftData.class)));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity>> capture = ArgumentCaptor.forClass(List.class);
verify(repository).replaceParties(anyLong(), capture.capture());
assertThat(capture.getValue()).singleElement().satisfies(party -> {
assertThat(party.getContactEmail()).isEqualTo("ops@example.test");
assertThat(party.getTaxNo()).isEqualTo("TEST-TAX");
});
var draft = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(draft);
var parties = parties(); parties.get(0).setPartyName("");
when(repository.parties(1L)).thenReturn(parties);
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "missing-party", new ReleaseRequest(3, null)))
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
verify(repository, never()).transition(anyLong(), any(), any(), anyInt(), anyLong(), any());
}
private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
@@ -510,7 +412,7 @@ class OceanBookingServiceTest {
private static DraftData partialDraft(String remark) {
return new DraftData(null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, remark, null, null, null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, remark, null, null);
}
private static DraftData completeDraft() {
@@ -518,11 +420,11 @@ class OceanBookingServiceTest {
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
"LAX", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null,
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null, null, null)),
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
List.of(new ContainerRequirementRequest("40HQ", "DRY", 2,
new BigDecimal("68"), new BigDecimal("19500"))), null, null, null, null, null, null, null, null, null, null, null);
new BigDecimal("68"), new BigDecimal("19500"))));
}
private static OceanBookingEntity completeBooking(String status, int version) {
@@ -9,20 +9,8 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentForecastServiceTest {
/** 无船期预计观察时仍返回日历所用的柜级到港事实,供海外统一推算日期。 */
@Test void returnsTheCalendarArrivalEvenWhenLatestForecastIsAbsent() {
var mapper=mock(AppointmentForecastMapper.class);
var sources=mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class);
var arrival=LocalDateTime.of(2026,10,30,23,30);
when(sources.byIds(List.of(1L))).thenReturn(List.of(
new com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource(1L,"D0001","CONT1",
null,null,null,null,arrival,"ACTUAL")));
var result=new AppointmentForecastService(mapper,sources).batch(List.of(1L));
assertThat(result.get(0).latest()).isNull();
assertThat(result.get(0).portArrivalAt()).isEqualTo(arrival);
}
@Test void preservesMissingForecastAndSourceLocalTimesWithoutSynthesizingEta() {
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class));
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
var observed=LocalDateTime.of(2026,10,10,12,0);
var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null);
var previous=new AppointmentForecastMapper.Fact(1L,10L,"carrier","REF-OLD",observed.minusDays(1),observed.plusDays(5),null);
@@ -33,7 +21,7 @@ class AppointmentForecastServiceTest {
verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper);
}
@Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() {
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class));
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
for(var ids:List.of(List.<Long>of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L)))
assertThatThrownBy(()->service.batch(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
@@ -1,190 +0,0 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.AdditionalAnswers.delegatesTo;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
/** 用真实 Mapper SQL、事务代理和独立连接验证完成检查点的租约隔离。 */
class LoadingCompletionCheckpointLeaseTest {
private JdbcDataSource dataSource;
private JdbcTemplate jdbc;
private LoadingCompletionCommandMapper mapper;
private LoadingCompletionCommandRepository commands;
/** 每个用例使用独立内存数据库,不访问共享环境。 */
@BeforeEach
void setup() throws Exception {
dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:completion-lease-" + UUID.randomUUID()
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=1000");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("""
CREATE TABLE fl_loading_completion_command(
id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP,
attempt_count INT,next_retry_at TIMESTAMP,updated_at TIMESTAMP,completed_at TIMESTAMP,
failure_stage VARCHAR(40),failure_code VARCHAR(32),failure_request_id VARCHAR(64),failure_trace_id VARCHAR(64))
""");
jdbc.execute("""
CREATE TABLE fl_loading_completion_checkpoint(
command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),
updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))
""");
jdbc.update("""
INSERT INTO fl_loading_completion_command(id,command_status,lease_owner,lease_until,attempt_count,next_retry_at)
VALUES(900,'PROCESSING','old-owner',DATEADD('HOUR',1,CURRENT_TIMESTAMP),1,CURRENT_TIMESTAMP)
""");
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(LoadingCompletionCommandMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setConfiguration(configuration);
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LoadingCompletionCommandMapper.class);
commands = repository(mapper);
}
/** 生产 Repository 的注解事务必须真实生效,覆盖锁定与写入两条 SQL。 */
private LoadingCompletionCommandRepository repository(LoadingCompletionCommandMapper targetMapper) {
var target = new LoadingCompletionCommandRepository();
ReflectionTestUtils.setField(target, "mapper", targetMapper);
var proxy = new ProxyFactory(target);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),
new AnnotationTransactionAttributeSource()));
return (LoadingCompletionCommandRepository) proxy.getProxy();
}
/** 模拟原执行者续租后暂停,租约过期并被新执行者领取推进,再恢复旧线程。 */
@Test
void pausedOldOwnerCannotDowngradeTheNewOwnersCompletedCheckpoint() throws Exception {
var renewed = new CountDownLatch(1);
var resumeOldOwner = new CountDownLatch(1);
var executor = Executors.newSingleThreadExecutor();
try {
var oldWrite = executor.submit(() -> {
assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isTrue();
renewed.countDown();
assertThat(resumeOldOwner.await(10, TimeUnit.SECONDS)).isTrue();
return commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
});
assertThat(renewed.await(10, TimeUnit.SECONDS)).isTrue();
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
assertThat(commands.claim(900, "new-owner", LocalDateTime.now().plusMinutes(10))).isTrue();
assertThat(commands.checkpoint(900, "new-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue();
resumeOldOwner.countDown();
assertThat(oldWrite.get(10, TimeUnit.SECONDS)).isFalse();
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "SETTLED")).isZero();
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
assertThat(jdbc.queryForObject("SELECT lease_owner FROM fl_loading_completion_command WHERE id=900", String.class))
.isEqualTo("new-owner");
} finally {
resumeOldOwner.countDown();
executor.shutdownNow();
}
}
/** 锁定后的实际写入前,独立数据库连接仍不能取得同一命令行的锁。 */
@Test
void commandRowRemainsLockedAcrossTheLeaseCheckAndCheckpointInsert() {
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
doAnswer(call -> {
assertThatThrownBy(() -> {
try (var connection = dataSource.getConnection();
var statement = connection.prepareStatement("SELECT id FROM fl_loading_completion_command WHERE id=900 FOR UPDATE NOWAIT")) {
statement.executeQuery().close();
}
}).isInstanceOf(SQLException.class)
.satisfies(error -> assertThat(((SQLException) error).getErrorCode()).isEqualTo(50200));
return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
call.getArgument(3), call.getArgument(4), call.getArgument(5));
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY");
}
/** 初次锁定检查后若租约失效,实际 INSERT 也必须拒绝,不能仅依赖前一次检查。 */
@Test
void checkpointInsertRechecksLeaseValidityAfterTheInitialLock() {
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
doAnswer(call -> {
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
call.getArgument(3), call.getArgument(4), call.getArgument(5));
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
assertThat(commands.checkpoint(900, 101, 50)).isNull();
}
/** 驱动将已持久化的幂等重放计为零行时,仍须以锁内当前有效租约确认。 */
@Test
void zeroAffectedRowsCanConfirmAnIdempotentCheckpointUnderTheSameValidLease() {
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
doAnswer(call -> {
mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
call.getArgument(3), call.getArgument(4), call.getArgument(5));
return 0;
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY");
}
/** 已过期但尚未被重新领取的所有者也不能写检查点、续租或写命令终态。 */
@Test
void expiredLeaseCannotWriteCheckpointRenewFailOrComplete() {
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero();
assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isFalse();
assertThat(commands.fail(900, "old-owner", "FAILED", LocalDateTime.now(), "LEASE_LOST", "A0409", null, null)).isFalse();
assertThat(commands.complete(900, "old-owner")).isFalse();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_completion_checkpoint", Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT command_status FROM fl_loading_completion_command WHERE id=900", String.class))
.isEqualTo("PROCESSING");
}
/** 同一当前所有者的重复或迟到阶段也只能保持或推进,不能降低完成检查点。 */
@Test
void validOwnerCheckpointStagesAreMonotonic() {
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED")).isTrue();
commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("SETTLED");
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue();
commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED");
commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
}
/** 完成或失败后的命令不允许追加检查点,即使所有者与租期字段仍有旧值。 */
@Test
void terminalCommandCannotAcceptAnotherCheckpoint() {
jdbc.update("UPDATE fl_loading_completion_command SET command_status='COMPLETED' WHERE id=900");
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero();
}
}
@@ -31,8 +31,6 @@ class LoadingCompletionOwnerPersistenceTest {
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,deleted INT,package_count INT)");
jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR,picked_package_count INT)");
jdbc.execute("CREATE TABLE fl_loading_completion_checkpoint(command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))");
jdbc.execute("CREATE TABLE fl_loading_completion_command(id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP)");
jdbc.update("INSERT INTO fl_loading_completion_command VALUES(900,'PROCESSING','owner-test',DATEADD('HOUR',1,CURRENT_TIMESTAMP))");
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10)");
row(1, 101, 50, 3, "PARTIAL", 2, 10, 0);
row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history
@@ -78,11 +76,11 @@ class LoadingCompletionOwnerPersistenceTest {
@Test
void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() {
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "READY");
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "SETTLED");
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "INBOUND_COMPLETED");
checkpoints.checkpointCompleted(900, "owner-test", 202, 60, 2, "INBOUND_COMPLETED");
checkpoints.checkpointCompleted(900, "owner-test", 303, 70, 3, "READY");
checkpoints.checkpointCompleted(900, 101, 50, 1, "READY");
checkpoints.checkpointCompleted(900, 101, 50, 1, "SETTLED");
checkpoints.checkpointCompleted(900, 101, 50, 1, "INBOUND_COMPLETED");
checkpoints.checkpointCompleted(900, 202, 60, 2, "INBOUND_COMPLETED");
checkpoints.checkpointCompleted(900, 303, 70, 3, "READY");
assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED");
assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY");
@@ -8,124 +8,22 @@ import com.baiyue.firstleg.repository.LoadingPickRepository;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.RejectedExecutionException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({LoadingCompletionWorker.class, LoadingCompletionWorkerTest.TransactionEvents.class})
@SpringJUnitConfig(LoadingCompletionWorker.class)
class LoadingCompletionWorkerTest {
/** 与生产事务装配一致,注册事务事件工厂,避免普通事件工厂立即调用监听器。 */
@Configuration
@EnableTransactionManagement
static class TransactionEvents {}
@MockBean private LoadingCompletionCommandRepository commands;
@MockBean private LoadingPickRepository picks;
@MockBean private LoadingPickService loadingPicks;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
@jakarta.annotation.Resource private LoadingCompletionWorker worker;
@jakarta.annotation.Resource private ApplicationEventPublisher events;
/** 真实事务同步回调必须在提交后才将完成工作交给执行器。 */
@Test
void committedAcceptanceStartsImmediatelyThroughTheDurableLease() {
LoadingCompletionCommandEntity command = command(0);
when(commands.command(900L)).thenReturn(command);
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
transactions().executeWithoutResult(status -> {
events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
verify(completionExecutor, never()).execute(any());
verify(loadingPicks, never()).processCompletion(any(), any());
});
ArgumentCaptor<Runnable> work = ArgumentCaptor.forClass(Runnable.class);
verify(completionExecutor).execute(work.capture());
verify(loadingPicks, never()).processCompletion(any(), any());
work.getValue().run();
verify(commands).claim(eq(900L), any(String.class), any(LocalDateTime.class));
verify(loadingPicks).processCompletion(eq(command), any(String.class));
}
/** 回滚后的命令没有权威落库事实,不允许唤醒或执行结算。 */
@Test
void rolledBackAcceptanceNeverSchedulesCompletion() {
transactions().executeWithoutResult(status -> {
events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
status.setRollbackOnly();
});
verify(completionExecutor, never()).execute(any());
verify(commands, never()).command(900L);
}
/** 即时唤醒不能绕过定时执行者已取得的租约。 */
@Test
void immediateWakeupDoesNotProcessAnAlreadyLeasedCommand() {
when(commands.command(900L)).thenReturn(command(0));
worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
ArgumentCaptor<Runnable> work = ArgumentCaptor.forClass(Runnable.class);
verify(completionExecutor).execute(work.capture());
work.getValue().run();
verify(loadingPicks, never()).processCompletion(any(), any());
}
/** 执行器拒绝时受理不报错,定时扫描仍可从原命令恢复。 */
@Test
void rejectedWakeupLeavesScheduledRecoveryAvailable() {
doThrow(new RejectedExecutionException("busy")).when(completionExecutor).execute(any());
LoadingCompletionCommandEntity command = command(0);
when(commands.due(10)).thenReturn(List.of(command));
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
worker.dispatch();
verify(loadingPicks).processCompletion(eq(command), any(String.class));
}
/** 同进程重领已到期命令也必须更换所有者,旧线程不能续租或落库。 */
@Test
void everyClaimUsesAnIndependentLeaseOwner() {
LoadingCompletionCommandEntity command = command(0);
when(commands.due(10)).thenReturn(List.of(command));
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
worker.dispatch();
worker.dispatch();
ArgumentCaptor<String> owners = ArgumentCaptor.forClass(String.class);
verify(loadingPicks, times(2)).processCompletion(eq(command), owners.capture());
assertThat(owners.getAllValues()).doesNotHaveDuplicates();
}
/** 隔离数据库依赖,仅用 Spring 的真实事务同步机制核对提交、回滚边界。 */
private static TransactionTemplate transactions() {
return new TransactionTemplate(new AbstractPlatformTransactionManager() {
/** 为每次测试提供独立事务对象。 */
@Override protected Object doGetTransaction() { return new Object(); }
/** 测试事务不访问数据库。 */
@Override protected void doBegin(Object transaction, TransactionDefinition definition) {}
/** 让 Spring 正常触发提交同步回调。 */
@Override protected void doCommit(DefaultTransactionStatus status) {}
/** 让 Spring 正常触发回滚同步回调。 */
@Override protected void doRollback(DefaultTransactionStatus status) {}
});
}
@Test
void transientFailureIsReleasedForBackoffRetry() {
@@ -50,9 +50,6 @@ import org.mockito.InOrder;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
@@ -80,9 +77,7 @@ import static org.mockito.Mockito.clearInvocations;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitConfig(LoadingPickService.class)
@RecordApplicationEvents
class LoadingPickServiceTest {
@Autowired private ApplicationEvents completionEvents;
@MockBean
private LoadingPickRepository repository;
@MockBean
@@ -159,8 +154,6 @@ class LoadingPickServiceTest {
when(repository.activeBookingByTask(20L)).thenReturn(booking);
when(completionCommands.insert(any())).thenReturn(1);
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
when(completionCommands.checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()))
.thenReturn(true);
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
when(repository.inboundOwners(anyLong())).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
@@ -938,7 +931,6 @@ class LoadingPickServiceTest {
assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
verify(repository, never()).taskForUpdate(20L);
verify(completionCommands, times(1)).insert(any());
assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class).count()).isEqualTo(1);
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
}
@@ -1005,8 +997,6 @@ class LoadingPickServiceTest {
assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
verify(completionCommands).resume(902L);
assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class)
.map(LoadingPickService.CompletionAccepted::commandId)).containsExactly(902L);
verify(repository).resumeCompletion(20L);
verify(completionCommands, never()).insert(any());
}
@@ -1099,7 +1089,7 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString());
verify(completionCommands, never()).checkpoint(eq(931L), eq(303L), eq(70L));
verify(completionCommands, never()).checkpoint(eq(931L), anyString(), anyLong(), anyLong(), anyInt(), anyString());
verify(completionCommands, never()).checkpoint(eq(931L), anyLong(), anyLong(), anyInt(), anyString());
verify(loadingListService).publishCompletedEvent(10L);
verify(completionCommands).complete(931L, "worker-completed-checkpoints");
}
@@ -1119,31 +1109,6 @@ class LoadingPickServiceTest {
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
/** 任一检查点被租约拒绝后都不得继续下一业务步骤或最终完成柜次。 */
@ParameterizedTest
@ValueSource(strings = {"READY", "SETTLED", "INBOUND_COMPLETED"})
void lostLeaseAtCheckpointStopsFurtherCompletion(String rejectedStage) {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
when(completionCommands.checkpoint(eq(940L), eq("old-owner"), eq(101L), eq(50L), eq(1), eq(rejectedStage)))
.thenReturn(false);
assertThatThrownBy(() -> service.processCompletion(completionCommand(940L, "lost-checkpoint"), "old-owner"))
.isInstanceOf(LoadingPickService.CompletionProcessingException.class)
.satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage())
.isEqualTo("LEASE_LOST"));
if ("READY".equals(rejectedStage)) {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
}
if (!"INBOUND_COMPLETED".equals(rejectedStage)) {
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString());
}
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(completionCommands, never()).complete(anyLong(), anyString());
}
@Test
void anEntirelySkippedTaskCannotCreateACompletionCommand() {
when(repository.taskForUpdate(20L)).thenReturn(task());
@@ -1306,8 +1271,8 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString());
verify(completionCommands).checkpoint(904L, "worker-failure", 101L, 50L, 1, "READY");
verify(completionCommands, never()).checkpoint(eq(904L), anyString(), anyLong(), anyLong(), anyInt(),
verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY");
verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(),
eq("SETTLED"));
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
@@ -1335,7 +1300,7 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString());
verify(completionCommands, never()).checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
verify(completionCommands, never()).checkpoint(anyLong(), anyLong(), anyLong(), anyInt(), anyString());
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
@@ -46,8 +46,6 @@ class ContainerCalendarSourceMapperTest {
var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L));
assertEquals(2, calendar.size());
var explicit = session.getMapper(ContainerCalendarSourceMapper.class).byIds(java.util.List.of(1L,2L));
assertEquals(calendar,explicit);
assertEquals(2, workbench.size());
for (int index = 0; index < calendar.size(); index++) {
var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00");
@@ -36,8 +36,8 @@ class WorkflowBoundaryTest {
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
}
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
@Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
var service=new WorkflowTabService(repository,downstream);
@@ -45,21 +45,12 @@ class WorkflowBoundaryTest {
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
verifyNoInteractions(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
verifyNoInteractions(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(11,1,false,false,false,false))));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
clearInvocations(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,true)));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
}
@@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
/** 取消回显、分柜隔离和资料页签回归。 */
class WorkflowBusinessTest {
/** 实际开船前保留出口报关,实际开船后再按清关资料进入海外待办。 */
/** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false);
@@ -18,9 +18,9 @@ class WorkflowBusinessTest {
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(completed,"LOADED",null))
.containsExactly("EXPORT_CUSTOMS");
.containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
.containsExactly("EXPORT_CUSTOMS");
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
.containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
+3 -4
View File
@@ -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` |
+7 -7
View File
@@ -22,10 +22,10 @@
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜,并由 `ContainerWarehouseArrivalService` 统一解析:人工维护日期或已有计划冻结日期优先,未维护未建计划时读取 Firstleg 权威到港当地日期加两个自然日;缺失或同柜不一致时阻断,清关提柜、日历和人工/自动计划统一使用 `plannedDate`,默认日期不补零点,首个计划创建后按已有计划日期冻结;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java`、`service/LastlegDocumentNumberGenerator.java`;头程来源使用14位 `WC + yyyyMMdd + 四位流水`,建单经 `service/LoadingCompletedConsumer.java` 在来源柜锁内复用已有号,正式格式见工作区 `docs/业务单据编号契约.md` |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
| 订单查询与更新 | `service/LastlegOrderService.java` |
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
@@ -85,8 +85,8 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含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` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
@@ -112,7 +112,7 @@
## 事件建单规则
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
- 同一冻结来源柜 `sourceContainerBookingId` 共用一个14位 `WC + yyyyMMdd + 四位流水` 尾程单号;首个交接事件在来源柜行锁内取号,后续客户或跨日交接复用该柜已保存的编号,事件重放不取新号。不同来源柜复用真实集装箱号时仍分别编号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。真实 `containerNo` 继续保留作实箱快照,`cabinetNo` 仅为柜次展示,不替代稳定来源柜身份。
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
@@ -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 及其他消费者仍由门禁拒绝。
-5
View File
@@ -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.'));
@@ -11,7 +11,7 @@ public interface AppointmentForecastClient {
record Request(List<Long> containerBookingIds) { }
record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference,
LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { }
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis,LocalDateTime portArrivalAt) { }
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis) { }
@PostMapping("/internal/firstleg/appointment-forecasts/batch")
ApiResponse<List<Forecast>> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request);
}
@@ -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);
}
@@ -12,31 +12,24 @@ public interface WarehouseCalendarMapper {
@Select("""
<script>
SELECT * FROM (
SELECT o.source_container_booking_id sourceContainerBookingId,
MAX(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.payload_json,'$.cabinetNo')),'null')) cabinetNo,
MAX(o.container_no) containerNo,
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
AND MAX(o.arrival_time_version)>0
AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id))
THEN MAX(o.estimated_warehouse_at) END plannedAt,
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version,
(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) planCount,
FALSE canEdit,
(SELECT CASE WHEN COUNT(p.planned_arrival_date)=COUNT(*) AND COUNT(DISTINCT p.planned_arrival_date)=1
THEN MAX(p.planned_arrival_date) END FROM ll_inbound_plan p
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) plannedDate,
FALSE isDefault
FROM ll_order o LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id
WHERE o.source_container_booking_id IS NOT NULL
FALSE canEdit
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL
GROUP BY o.source_container_booking_id
) c WHERE
(c.plannedAt &gt;= #{from} AND c.plannedAt &lt; #{to})
OR (c.plannedDate &gt;= DATE(#{from}) AND c.plannedDate &lt; DATE(#{to}))
<if test="ids != null and !ids.isEmpty()">
OR c.sourceContainerBookingId IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
@@ -14,7 +14,7 @@ public final class WarehouseCalendarModels {
private WarehouseCalendarModels() { }
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
public record Entry(String sourceContainerBookingId, String cabinetNo, String containerNo, String warehouseName,
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName,
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
long planCount, boolean canEdit, LocalDate plannedDate, boolean isDefault) { }
long planCount, boolean canEdit) { }
}
@@ -10,10 +10,8 @@ import java.util.List;
@Service
public class WarehouseCalendarService {
private final WarehouseCalendarRepository repository;
private final com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals;
/** 装配日历只读仓储查询。 */
public WarehouseCalendarService(WarehouseCalendarRepository repository,
com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals) { this.repository = repository; this.arrivals=arrivals; }
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; }
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
@@ -22,16 +20,8 @@ public class WarehouseCalendarService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
var rows = repository.calendar(query);
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
var resolved=arrivals.resolve(rows.stream().map(WarehouseCalendarModels.Entry::sourceContainerBookingId).toList())
.stream().collect(java.util.stream.Collectors.toMap(
com.baiyue.lastleg.dto.ContainerArrivalTimeView::sourceContainerBookingId,java.util.function.Function.identity()));
return rows.stream().map(row -> {
var arrival=resolved.get(row.sourceContainerBookingId());
if(arrival==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到仓日期读取不完整");
return new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.cabinetNo(), row.containerNo(),
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null,
arrival.plannedDate(),arrival.isDefault());
}).toList();
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(),
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList();
}
}
@@ -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) {}
}
@@ -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")
@@ -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());
@@ -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));
@@ -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());
}
}
@@ -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")
@@ -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) {
@@ -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,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);
@@ -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明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */
@@ -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}
""")
@@ -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>";
@@ -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);
@@ -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));
}
/** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */
@@ -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());
}
}
@@ -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() { }
@@ -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) { }
}
@@ -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, Maintenance maintenance) {
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); }
@@ -1,7 +1,5 @@
package com.baiyue.lastleg.dispatch.port;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
@@ -27,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,
@@ -44,64 +34,14 @@ public final class DispatchContracts {
public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone,
DriverEvidence evidence) { }
/** 本趟可空维护资料,不替代正式预约或实际发车事实。 */
public record Maintenance(@JsonFormat(shape = JsonFormat.Shape.STRING)
@JsonDeserialize(using = DispatchMaintenanceDateDeserializer.class)
LocalDate plannedDeliveryDate, String deliveryMethod, String pickupMethod,
String loadType, String fleetEmail, String vehicleType,
String appointmentAccount, String appointmentNo) {
/** 空白统一为空;只约束已填写的选项及长度,不阻止空草稿保存。 */
public Maintenance {
deliveryMethod = option(deliveryMethod, "派送方式", "FBA自提", "客户自提", "卡派打托", "卡派地板", "快递", "外配");
pickupMethod = option(pickupMethod, "提卸方式", "一提一卸", "一提多卸");
loadType = option(loadType, "装载类型", "托盘", "地板", "混装");
vehicleType = option(vehicleType, "车辆类型", "厢式货车", "平板车", "集装箱卡车");
fleetEmail = limited(fleetEmail, "车队邮箱", 254);
appointmentAccount = limited(appointmentAccount, "预约账号", 128);
appointmentNo = limited(appointmentNo, "本趟预约号", 128);
}
/** 选项按正式文案保存,拒绝未知值而不猜测业务含义。 */
private static String option(String value, String label, String... allowed) {
String normalized = limited(value, label, 128);
if (normalized != null && !List.of(allowed).contains(normalized)) throw new IllegalArgumentException(label + "无效");
return normalized;
}
/** 统一文本空值及长度边界,不把补充资料写入备注或车牌。 */
private static String limited(String value, String label, int max) {
String normalized = value == null || value.isBlank() ? null : value.trim();
if (normalized != null && normalized.length() > max) throw new IllegalArgumentException(label + "最多" + max + "字符");
return normalized;
}
}
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver) { }
public record Destination(String key, String type, String warehouseId, String warehouseCode,
String warehouseName, String address, String postalCode,
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) { }
@@ -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);
@@ -1,29 +0,0 @@
package com.baiyue.lastleg.dispatch.port;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
/** 本趟补充日期允许清空,但不能将时间串或不存在的日期截断、修正为有效日期。 */
public final class DispatchMaintenanceDateDeserializer extends StdDeserializer<LocalDate> {
/** 仅绑定维护资料的可空日期,不改变其他业务日期解析规则。 */
public DispatchMaintenanceDateDeserializer() { super(LocalDate.class); }
/** 空白归空,其余只接受四位年、两位月日的严格ISO日期。 */
@Override
public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException {
if (!parser.hasToken(JsonToken.VALUE_STRING)) return (LocalDate) context.handleUnexpectedToken(LocalDate.class, parser);
String value = parser.getText().trim();
if (value.isEmpty()) return null;
try {
if (!value.matches("\\d{4}-\\d{2}-\\d{2}")) throw new DateTimeParseException("日期格式无效", value, 0);
return LocalDate.parse(value);
} catch (DateTimeParseException failure) {
return (LocalDate) context.handleWeirdStringValue(LocalDate.class, value, "计划派送日期须为有效的YYYY-MM-DD");
}
}
}
@@ -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
}
}
@@ -36,7 +36,7 @@ public final class DispatchViews {
boolean pickingReconciled, boolean arranged,
Map<Action, Gate> actions) { }
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver) { }
public record FbaAppointment(String batchKey, String batchNo, String appointmentNo,
String destinationKey, String destinationWarehouseCode,
@@ -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,
@@ -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) {
@@ -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); }
/** 真实库存数量及版本。 */
@@ -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,null); 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);
@@ -373,14 +358,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion());
if(trim(request.reason())==null)throw bad("取消原因不能为空");
if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消");
// 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。
var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
var items=repository.cargo(row.getId());lockExecutionSources(row,items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
releaseSources(row,actor,items,now());
fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消");
return saveCommand(row,actor,identity,request.reason());
}
/** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */
/** 旧新资源身份一次锁齐,编辑关键依据后两级审核均失效,普通备注不篡改旧审核。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
requirePlatform(actor);
@@ -389,15 +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 vehiclePhotos=request.vehicle()==null?null:request.vehicle().evidenceFileIds();
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
row.setResourcesJson(write(resources(actual,photos,request.maintenance())));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());
@@ -411,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());
}
@@ -422,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());
@@ -437,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());
}
@@ -540,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));
@@ -553,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);
@@ -565,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());
@@ -621,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));}
/** 每次状态变更、历史及不可变命令结果同事务保存。 */
@@ -643,58 +604,26 @@ 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("请补齐车辆真实托位、载重及容积");
var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间");
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
if(v.mode()==ResourceMode.EXTERNAL || v.evidenceFileIds()!=null && !v.evidenceFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
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(),r.maintenance()))))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) {
@@ -702,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) {
@@ -753,20 +648,7 @@ 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) {
var row=required(actor,number,false); var cargo=repository.cargo(row.getId());
@@ -776,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(),resources.maintenance()),
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()));
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),
documents,orderDetails(cargo));
}
/** 恢复动作结果前重新核验当前单据范围和原动作权限。 */
@@ -842,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);
@@ -892,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());
@@ -927,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";
@@ -963,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);
@@ -973,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])));
@@ -1004,12 +848,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return List.copyOf(result);
}
/** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos,Maintenance maintenance) {
/** 把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)),maintenance);
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt())));
}
/** 主档选择不接受请求覆盖其车牌和容量。 */
@@ -1039,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())); }
/** 空单永远不能核平为装车完成。 */
@@ -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();
@@ -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; }
}
@@ -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;
};
}
@@ -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) + "}";
@@ -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));
}
}
@@ -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"));
}
}
@@ -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作业");}
@@ -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());}
@@ -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());
}
}
@@ -382,18 +382,6 @@ public class DispatchService {
});
}
/** 取消只锁本车待释放的申请与原实托,不以当前库存、实收或组托完整性作为撤销安排的资格。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockCancellationSources(String warehouse,List<DispatchItem> items) {
if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("取消排车仍须明确起运仓库");
var applications=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).collect(Collectors.toCollection(TreeSet::new));
// 先锁实际需要恢复预占的换标申请,再取得共同实托串行点;计划来源不伪造实托或查询库存。
if(!applications.isEmpty())lockRelabelApplications(applications);
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
if(!pallets.isEmpty())palletSerial.lock(pallets);
}
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
@@ -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,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);}
}
@@ -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));}

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