Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ad357d259 |
@@ -133,9 +133,9 @@
|
||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||
- 通用配置在 `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/事件消费者。
|
||||
- 开发迭代使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及直接调用点;Java 签名变化至少使用 `-Mode Compile` 编译生产与测试源码(等价于 `mvn -pl <modules> -am test-compile`)。单服务行为变更收尾使用默认 `Module` 模式执行该服务及上游依赖测试;跨服务 DTO、接口或事件变化必须显式列入生产者和受影响消费者。`-am` 只补上游构建依赖,不自动包含下游服务或 HTTP/事件消费者;不能以单类测试代替模块收尾。
|
||||
- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。
|
||||
- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 POM、依赖或测试配置改动先按实际影响选编译或测试,不一律全仓。入口为 `scripts/backend-full-regression.ps1`,CI 全量保留。普通提交、单服务任务或对话收尾不触发;有效结果复用,输入变化只补跑受影响范围。
|
||||
- 全仓回归只在以下情况触发:根 POM/统一依赖版本/聚合拓扑/测试基础配置变化;公共认证、序列化、基础模型等影响广泛且不能可靠限定消费者;跨多域批次集成验收;用户明确要求全量回归。使用 `scripts/backend-full-regression.ps1`,保留当前 CI 全量验证。普通单服务任务、单次提交或对话收尾不自动触发全仓;已明确消费者的公共或跨服务变更按受影响模块验证。同批同一相关输入只跑一次,输入变化后补跑受影响范围,触及上述全量条件再重跑全量。
|
||||
- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
||||
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||
|
||||
Vendored
+2
-35
@@ -67,8 +67,6 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||
List<String> failedServices = []
|
||||
if (state.startsWith('RESULTS')) {
|
||||
String summary = state.substring('RESULTS'.length()).trim()
|
||||
int metadataIndex = summary.indexOf(' | ')
|
||||
if (metadataIndex >= 0) summary = summary.substring(0, metadataIndex).trim()
|
||||
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||
int separator = item.indexOf('=')
|
||||
if (separator > 0) {
|
||||
@@ -105,25 +103,6 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 测试范围必须相对成功门禁,而非失败/取消轮次;否则补跑可能遗漏前次未通过的变更。
|
||||
def previousSuccessfulTestCommit(def previousBuild, List<String> requiredServices) {
|
||||
def candidate = previousBuild
|
||||
int inspected = 0
|
||||
while (candidate != null && inspected < 30) {
|
||||
String description = candidate.description ?: ''
|
||||
String marker = ' | TESTS=PASSED '
|
||||
int markerIndex = description.lastIndexOf(marker)
|
||||
if (candidate.result == 'SUCCESS' && markerIndex >= 0) {
|
||||
List metadata = description.substring(markerIndex + marker.length()).trim().split(' ')
|
||||
if (metadata.size() == 2 && metadata[0] ==~ /[0-9a-fA-F]{7,40}/ &&
|
||||
requiredServices.every { metadata[1].split(',').contains(it) }) return metadata[0]
|
||||
}
|
||||
candidate = candidate.previousBuild
|
||||
inspected++
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
def environmentChoicesForJob(String jobBaseName) {
|
||||
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
||||
}
|
||||
@@ -182,9 +161,7 @@ pipeline {
|
||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
||||
choice(name: 'TEST_SCOPE', choices: ['AUTO', 'MODULE'], description: 'AUTO 对已支持的局部变更执行关联测试;公共/未知变更自动完整模块回归;MODULE 显式完整模块回归')
|
||||
// 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
||||
}
|
||||
@@ -291,7 +268,6 @@ pipeline {
|
||||
selected = selected.unique()
|
||||
|
||||
env.SELECTED_SERVICES = selected.join(',')
|
||||
env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected)
|
||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||
if (!selected) {
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
@@ -358,22 +334,16 @@ pipeline {
|
||||
.findAll { it }
|
||||
.join(',')
|
||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||
String testPlan = "${pwd(tmp: true)}/ci-test-plan.json"
|
||||
lock(resource: 'firstleg-test-maven') {
|
||||
timeout(time: 50, unit: 'MINUTES') {
|
||||
sh """
|
||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||
node deploy/scripts/ci-test-scope.mjs plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}'
|
||||
node deploy/scripts/ci-test-scope.mjs checks --plan '${testPlan}'
|
||||
test_arguments=\"\$(node deploy/scripts/ci-test-scope.mjs args --plan '${testPlan}')\"
|
||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test \$test_arguments
|
||||
node deploy/scripts/ci-test-scope.mjs verify --plan '${testPlan}'
|
||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||
"""
|
||||
}
|
||||
}
|
||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||
env.TEST_GATE_PASSED = 'true'
|
||||
}
|
||||
}
|
||||
post {
|
||||
@@ -430,9 +400,6 @@ pipeline {
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
||||
: "${env.IMAGE_TAG} | ${summary}"
|
||||
if (env.TEST_GATE_PASSED == 'true') {
|
||||
currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}"
|
||||
}
|
||||
if (failed) {
|
||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||
}
|
||||
|
||||
+1
-2
@@ -49,8 +49,7 @@ pipeline {
|
||||
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||
// 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
||||
|
||||
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
||||
|
||||
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
|
||||
|
||||
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
|
||||
@@ -56,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 与部署须逐项授权。
|
||||
|
||||
@@ -1484,6 +1484,7 @@ CREATE TABLE fl_container_booking (
|
||||
KEY idx_container_booking_voyage (voyage_no,updated_at),
|
||||
KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK (((container_no is null) or regexp_like(container_no,_utf8mb4'^[A-Z]{3}[UJZ][0-9]{7}$'))),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))),
|
||||
CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))),
|
||||
CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))),
|
||||
@@ -3441,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 '订舱计划物流渠道组编码快照',
|
||||
@@ -3464,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 '订舱记录创建时间',
|
||||
@@ -3489,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承载';
|
||||
@@ -3583,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,
|
||||
@@ -6046,48 +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.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 '本趟主档或临时车辆司机的真实资源快照',
|
||||
|
||||
@@ -11,7 +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),
|
||||
@@ -80,9 +79,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 || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_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, "出车实测凭证必须归属平台");
|
||||
|
||||
@@ -6,7 +6,7 @@ info:
|
||||
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
||||
三类证据均固定平台 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、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
|
||||
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
||||
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
||||
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
||||
@@ -126,7 +126,7 @@ components:
|
||||
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
||||
DispatchEvidenceType:
|
||||
type: string
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE]
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE]
|
||||
MetadataEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
|
||||
@@ -30,7 +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;
|
||||
@@ -44,7 +43,7 @@ class DispatchEvidenceFileTest {
|
||||
|
||||
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO})
|
||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
@@ -66,10 +65,9 @@ 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);
|
||||
/** 司机本人照片使用独立平台类别,只保存真实图片,拒绝PDF和伪造图片。 */
|
||||
@org.junit.jupiter.api.Test void driverPhotoAcceptsOnlyRealImages() throws Exception {
|
||||
var storage=storage();var category=BusinessFileCategory.require(PHOTO);
|
||||
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);
|
||||
@@ -164,7 +162,7 @@ class DispatchEvidenceFileTest {
|
||||
|
||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO})
|
||||
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<>'SUPERSEDED' AND NOT EXISTS(SELECT 1 FROM fin_fee_source newer" +
|
||||
" WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type" +
|
||||
" AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)" +
|
||||
"</if>" +
|
||||
"<if test='customerId != null'> AND f.customer_id=#{customerId}</if>" +
|
||||
"<if test='direction != null'> AND f.direction=#{direction}</if>" +
|
||||
"<if test='reviewStatus != null'> AND f.review_status=#{reviewStatus}</if>" +
|
||||
"<if test='status != null'> AND " + STATUS + "=#{status}</if>" +
|
||||
"<if test='statementGenerated != null'> AND " + GENERATED + "=#{statementGenerated}</if>" +
|
||||
"<if test='dateFrom != null'> AND " + BUSINESS_DATE + ">=#{dateFrom}</if>" +
|
||||
"<if test='dateTo != null'> AND " + BUSINESS_DATE + "<=#{dateTo}</if>" +
|
||||
"<if test='keyword != null'> AND (f.source_no LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR f.fee_item_name LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',#{keyword},'%'))</if>";
|
||||
|
||||
/** 筛选和计数共用当前版本条件,不能先分页再去重。 */
|
||||
@Select("<script>SELECT COUNT(*)" + FILTER + "</script>")
|
||||
long count(Map<String, Object> filters);
|
||||
|
||||
/** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */
|
||||
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS + " effective_status" + FILTER +
|
||||
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<Map<String, Object>> list(Map<String, Object> filters);
|
||||
|
||||
String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" +
|
||||
" AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" +
|
||||
" AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" +
|
||||
" AND f.source_fee_version<=anchor.source_fee_version";
|
||||
|
||||
/** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */
|
||||
@Select("<script>SELECT f.id,f.source_fee_version,f.amount,f.currency_code,f.created_at" + LINEAGE +
|
||||
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId}" +
|
||||
" AND f.source_system IN ('FIRSTLEG','LASTLEG') ORDER BY f.source_fee_version,f.id</script>")
|
||||
List<Map<String, Object>> versions(@Param("customerId") long customerId, @Param("id") long id);
|
||||
|
||||
/** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */
|
||||
@Select("<script>SELECT a.id,a.created_at,a.action,a.operator_name_snapshot,a.reason,a.before_data,a.after_data" + LINEAGE +
|
||||
" JOIN fin_audit_log a ON a.tenant_id=f.customer_id AND a.resource_type='FIN_FEE_SOURCE' AND a.resource_id=f.id" +
|
||||
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId} ORDER BY a.created_at DESC,a.id DESC</script>")
|
||||
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("id") long id);
|
||||
}
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 费用当前投影及操作历史;版本记录只读,不覆盖账单引用的不可变快照。 */
|
||||
@Repository
|
||||
public class FinanceFeeSourceReadRepository {
|
||||
private final FinanceFeeSourceReadMapper mapper;
|
||||
private final ObjectMapper json;
|
||||
|
||||
/** 注入正式持久化入口与费用快照解析器。 */
|
||||
public FinanceFeeSourceReadRepository(FinanceFeeSourceReadMapper mapper, ObjectMapper json) {
|
||||
this.mapper = mapper;
|
||||
this.json = json;
|
||||
}
|
||||
|
||||
/** 组装统一查询参数,让计数和分页使用相同来源身份与筛选条件。 */
|
||||
public Map<String, Object> filters(Long customerId, String direction, String status, String reviewStatus,
|
||||
Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("customerId", customerId); values.put("direction", direction); values.put("status", status);
|
||||
values.put("reviewStatus", reviewStatus); values.put("statementGenerated", generated);
|
||||
values.put("dateFrom", from); values.put("dateTo", to); values.put("keyword", keyword);
|
||||
values.put("limit", size); values.put("offset", (page - 1) * size);
|
||||
return values;
|
||||
}
|
||||
|
||||
/** 计数只包含所筛选的当前费用,历史状态专项查询除外。 */
|
||||
public long count(Map<String, Object> filters) { return mapper.count(filters); }
|
||||
|
||||
/** 当前费用映射沿用原币、数量、客户等正式字段。 */
|
||||
public List<FeeSourceSummary> list(Map<String, Object> filters) {
|
||||
return mapper.list(filters).stream().map(this::summary).toList();
|
||||
}
|
||||
|
||||
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
|
||||
public List<OperationLog> logs(long customerId, long id) {
|
||||
List<OperationLog> result = new ArrayList<>();
|
||||
Map<String, Object> previous = null;
|
||||
for (Map<String, Object> raw : mapper.versions(customerId, id)) {
|
||||
Map<String, Object> row = row(raw);
|
||||
String version = "来源版本 " + row.get("source_fee_version");
|
||||
String amount = amount(row) + " " + row.get("currency_code");
|
||||
// 同步时间与原业务操作者不能混用,原业务审批仍以来源系统记录为准。
|
||||
result.add(new OperationLog(time(row.get("created_at")),
|
||||
previous == null ? "FEE_SOURCE_PUBLISHED" : "FEE_SOURCE_VERSION_ADJUST",
|
||||
"系统同步", "SUCCESS", previous == null ? version + ";金额 " + amount
|
||||
: "来源版本 " + previous.get("source_fee_version") + " → " + row.get("source_fee_version")
|
||||
+ ";金额 " + amount(previous) + " → " + amount));
|
||||
previous = row;
|
||||
}
|
||||
// 金额差分仍按版本升序计算;展示先反转版本,同毫秒时稳定排序保留新版本在前。
|
||||
java.util.Collections.reverse(result);
|
||||
for (Map<String, Object> raw : mapper.audits(customerId, id)) {
|
||||
Map<String, Object> row = row(raw);
|
||||
String remark = text(row.get("reason"));
|
||||
JsonNode before = parse(row.get("before_data"));
|
||||
JsonNode after = parse(row.get("after_data"));
|
||||
if (before.hasNonNull("amount") && after.hasNonNull("amount")
|
||||
&& before.path("amount").decimalValue().compareTo(after.path("amount").decimalValue()) != 0) {
|
||||
remark = (remark == null ? "" : remark + ";") + "金额 " + before.path("amount").asText()
|
||||
+ " → " + after.path("amount").asText() + " " + after.path("currency").asText("CNY");
|
||||
}
|
||||
result.add(new OperationLog(time(row.get("created_at")), text(row.get("action")),
|
||||
text(row.get("operator_name_snapshot")), "SUCCESS", remark));
|
||||
}
|
||||
// 时间排序保持同毫秒版本的新到旧顺序,以及 Mapper 返回的审计 ID 倒序。
|
||||
result.sort(Comparator.comparing(OperationLog::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 将持久化快照转换为费用列表 DTO,不从其他客户或来源补齐业务事实。 */
|
||||
private FeeSourceSummary summary(Map<String, Object> raw) {
|
||||
Map<String, Object> row = row(raw);
|
||||
JsonNode snapshot = parse(row.get("source_snapshot"));
|
||||
BigDecimal amount = decimal(row.get("amount"));
|
||||
BigDecimal originalAmount = first(decimal(text(snapshot, "originalAmount")), amount);
|
||||
BigDecimal quantity = decimal(text(snapshot, "chargeableQuantity"));
|
||||
BigDecimal unitPrice = first(decimal(text(snapshot, "unitPrice")), quantity != null && quantity.signum() > 0
|
||||
? originalAmount.divide(quantity, 6, RoundingMode.HALF_UP) : null);
|
||||
return new FeeSourceSummary(number(row.get("id")), text(row.get("source_system")), text(row.get("source_type")),
|
||||
first(text(row.get("source_no")), text(snapshot, "sourceBusinessId")), number(row.get("source_fee_id")),
|
||||
((Number) row.get("source_fee_version")).intValue(), text(row.get("direction")), number(row.get("customer_id")),
|
||||
text(snapshot, "customerName"), positiveLong(text(snapshot, "supplierId")), text(snapshot, "supplierName"),
|
||||
nullableLong(row.get("order_id")), nullableLong(row.get("inbound_id")), text(snapshot, "inboundCode"),
|
||||
nullableLong(row.get("container_id")), text(snapshot, "containerNo"),
|
||||
first(date(text(snapshot, "businessDate")), LocalDate.of(1970, 1, 1)), text(row.get("fee_item_name")),
|
||||
quantity, text(snapshot, "quantityUnit"), unitPrice, date(text(snapshot, "periodStart")), date(text(snapshot, "periodEnd")),
|
||||
amount, text(row.get("currency_code")), first(text(snapshot, "originalCurrency"), text(row.get("currency_code"))),
|
||||
originalAmount, first(decimal(text(snapshot, "exchangeRate")), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||
first(text(row.get("review_status")), "DRAFT"), text(row.get("effective_status")),
|
||||
Boolean.TRUE.equals(row.get("statement_generated")) || row.get("statement_generated") instanceof Number value && value.intValue() != 0);
|
||||
}
|
||||
|
||||
/** 保留不同数据库驱动的列名大小写读取语义。 */
|
||||
private static Map<String, Object> row(Map<String, Object> source) {
|
||||
Map<String, Object> result = new LinkedCaseInsensitiveMap<>(); result.putAll(source); return result;
|
||||
}
|
||||
/** 解析不可变快照,损坏数据失败关闭。 */
|
||||
private JsonNode parse(Object value) {
|
||||
try { return value == null ? json.createObjectNode() : json.readTree(value.toString()); }
|
||||
catch (Exception failure) { throw new FinanceEventException("FIN-DATA-500", "费用来源快照损坏"); }
|
||||
}
|
||||
/** 格式化记录金额,保留定点精度。 */
|
||||
private static String amount(Map<String, Object> row) { return decimal(row.get("amount")).toPlainString(); }
|
||||
/** 读取驱动返回的标准时间。 */
|
||||
private static LocalDateTime time(Object value) { return value instanceof Timestamp time ? time.toLocalDateTime() : (LocalDateTime) value; }
|
||||
/** 读取数字主键,仅用于数据引用。 */
|
||||
private static long number(Object value) { return ((Number) value).longValue(); }
|
||||
/** 保留未关联的可空引用。 */
|
||||
private static Long nullableLong(Object value) { return value == null ? null : number(value); }
|
||||
/** 只接受合法的正整型供应商引用。 */
|
||||
private static Long positiveLong(String value) {
|
||||
try { long parsed = Long.parseLong(value); return parsed > 0 ? parsed : null; }
|
||||
catch (NumberFormatException failure) { return null; }
|
||||
}
|
||||
/** 读取可空金额,不经过浮点转换。 */
|
||||
private static BigDecimal decimal(Object value) { return value == null ? null : new BigDecimal(value.toString()); }
|
||||
/** 读取可空业务日期。 */
|
||||
private static LocalDate date(String value) { return value == null ? null : LocalDate.parse(value); }
|
||||
/** 保持空字段的缺失语义。 */
|
||||
private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString(); }
|
||||
/** 读取快照字段。 */
|
||||
private static String text(JsonNode node, String field) { return node.path(field).isNull() || node.path(field).isMissingNode() ? null : text(node.path(field).asText()); }
|
||||
/** 优先使用来源字段,其次使用已有正式默认值。 */
|
||||
private static <T> T first(T value, T fallback) { return value == null ? fallback : value; }
|
||||
}
|
||||
+61
-12
@@ -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;
|
||||
|
||||
@@ -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`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `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`,提单文件继续归订舱。
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
## 国内货柜履约装柜与 PDA 资源结算
|
||||
|
||||
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺;该关联入口仅规范化空白和大小写,不以 ISO 长度、结构、设备类别或校验位阻断,数据库不设置 ISO 格式 CHECK,仍遵守同订舱唯一及已有柜号一致性。关联已接受的柜号在运输信息未改号时不重复做 ISO 校验;确认预装与创建 PDA 任务前必须校验 ISO 格式并提示在预装运输信息中纠错,避免实物作业后无法完成,PDA 完成及跨服务事件保持原有格式校验;运输或提柜首次录入及纠正为不同柜号保留原有规则。确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
|
||||
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
||||
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
|
||||
+7
-11
@@ -211,7 +211,6 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
/** 全部预录单经客户确认后发送报关行执行,保留版本与发送状态校验。 */
|
||||
@Transactional
|
||||
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
|
||||
@@ -222,8 +221,8 @@ public class ContainerCustomsService {
|
||||
ContainerRow container = mapper.lockContainer(planId);
|
||||
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送预录单确认结果给报关行");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有预录单未完成客户确认");
|
||||
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
|
||||
if (mapper.brokerSubmission(container.id()) == null) throw conflict("当前柜尚未完成首次发送报关行资料");
|
||||
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
|
||||
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
|
||||
@@ -232,7 +231,6 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
/** 上传预录单并保存文件版本,已发送报关行执行后仍禁止替换。 */
|
||||
@Transactional
|
||||
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, MultipartFile file) {
|
||||
@@ -248,7 +246,7 @@ public class ContainerCustomsService {
|
||||
throw conflict("当前客户资料不在报关中冻结版本");
|
||||
}
|
||||
if (mapper.brokerSubmittedAt(container.id()) != null) {
|
||||
throw conflict("客户确认的预录单已发送给报关行,不可再修改预录单");
|
||||
throw conflict("客户确认回执已发送给报关行,不可再修改回执");
|
||||
}
|
||||
if (mapper.brokerSubmission(container.id()) == null) {
|
||||
throw conflict("当前柜尚未完成首次发送报关行资料");
|
||||
@@ -302,7 +300,6 @@ public class ContainerCustomsService {
|
||||
return merchantViewForDocument(tenantId, source);
|
||||
}
|
||||
|
||||
/** 预录单确认并发送报关行执行后,接收真实报关完税文件。 */
|
||||
@Transactional
|
||||
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, MultipartFile file) {
|
||||
@@ -319,7 +316,7 @@ public class ContainerCustomsService {
|
||||
}
|
||||
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
|
||||
CompletionRow receipt = mapper.lockCompletion(documentId);
|
||||
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户的预录单尚未确认");
|
||||
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
|
||||
FinalRow existing = mapper.lockFinalDocument(documentId);
|
||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||
long finalId;
|
||||
@@ -374,7 +371,7 @@ public class ContainerCustomsService {
|
||||
if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次");
|
||||
if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) {
|
||||
throw conflict("当前柜尚未完成退税资料、预录单确认和报关行执行");
|
||||
throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行");
|
||||
}
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
long batchId = IdWorker.getId();
|
||||
@@ -539,15 +536,14 @@ public class ContainerCustomsService {
|
||||
row.operatorName(), row.createdAt())).toList();
|
||||
}
|
||||
|
||||
/** 按既有客户范围与可见状态下载预录单,沿用文件真实名称。 */
|
||||
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
|
||||
DocumentRow source = requiredDocument(documentId);
|
||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
|
||||
throw notFound("预录单不存在");
|
||||
throw notFound("报关行回执不存在");
|
||||
}
|
||||
CompletionRow completion = mapper.completion(documentId);
|
||||
if (completion == null) throw notFound("预录单不存在");
|
||||
if (completion == null) throw notFound("报关行回执不存在");
|
||||
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
||||
}
|
||||
|
||||
|
||||
+3
-29
@@ -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);
|
||||
}
|
||||
|
||||
-22
@@ -126,28 +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}/estimated-arrival")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ESTIMATED_ARRIVAL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="维护订舱预计到港")
|
||||
public ApiResponse<Detail> updateEstimatedArrival(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody EstimatedArrivalRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateEstimatedArrival(oceanBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
|
||||
@PutMapping("/{oceanBookingId}/actual-cargo")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ACTUAL_CARGO", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="补录订舱实际货量")
|
||||
public ApiResponse<Detail> updateActualCargo(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActualCargoRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateActualCargo(oceanBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')")
|
||||
@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,35 +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 EstimatedArrivalRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull LocalDateTime estimatedArrivalAt) {}
|
||||
|
||||
/** 放舱后的受控实货量补录,不接收运输资料。 */
|
||||
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Min(1) @Max(999999999) Integer actualPackageCount,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
|
||||
|
||||
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Valid @NotNull DraftData data) {}
|
||||
|
||||
-11
@@ -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;
|
||||
|
||||
-2
@@ -17,6 +17,4 @@ public class OceanBookingPartyEntity {
|
||||
private String address;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String contactEmail;
|
||||
private String taxNo;
|
||||
}
|
||||
|
||||
+1
-15
@@ -25,22 +25,8 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
""")
|
||||
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
|
||||
@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 estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND status='RELEASED' AND version=#{version}")
|
||||
int updateEstimatedArrival(@Param("id") long id, @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt,
|
||||
@Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
|
||||
actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},
|
||||
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{value.id} AND status='RELEASED' AND version=#{version}
|
||||
""")
|
||||
int updateActualCargo(@Param("value") OceanBookingEntity value, @Param("version") int version,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int deleteDraft(@Param("id") long id, @Param("version") int version);
|
||||
@Select("""
|
||||
|
||||
-9
@@ -54,15 +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 updateEstimatedArrival(long id, java.time.LocalDateTime estimatedArrivalAt, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateEstimatedArrival(id, estimatedArrivalAt, version, operatorId, operatorName);
|
||||
}
|
||||
|
||||
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
|
||||
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateActualCargo(value, version, operatorId, operatorName);
|
||||
}
|
||||
public int deleteDraft(long id, int version) {
|
||||
return bookings.deleteDraft(id, version);
|
||||
}
|
||||
|
||||
+8
-51
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
+1
-63
@@ -145,44 +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 updateEstimatedArrival(long id, long operatorId, String name, String key, EstimatedArrivalRequest request) {
|
||||
String action = "UPDATE_ESTIMATED_ARRIVAL";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.RELEASED);
|
||||
if (request.estimatedArrivalAt() == null) throw badRequest("预计到港时间不能为空");
|
||||
if (before.getEstimatedSailingAt() != null && request.estimatedArrivalAt().isBefore(before.getEstimatedSailingAt())) {
|
||||
throw badRequest("预计到港时间不能早于预计开船时间");
|
||||
}
|
||||
if (repository.updateEstimatedArrival(id, request.estimatedArrivalAt(), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity saved = required(id);
|
||||
addHistory(id, before.getStatus(), saved.getStatus(), action, "维护预计到港", before, saved, operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(saved));
|
||||
}
|
||||
|
||||
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
|
||||
@Transactional
|
||||
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
|
||||
String action = "UPDATE_ACTUAL_CARGO";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.RELEASED);
|
||||
if (!StringUtils.hasText(request.reason())) throw badRequest("请填写实货量补录或修正原因");
|
||||
validateCargo(request.actualPackageCount(), request.actualGrossWeightKg(), request.actualVolumeCbm());
|
||||
OceanBookingEntity after = objectMapper.convertValue(before, OceanBookingEntity.class);
|
||||
after.setActualPackageCount(request.actualPackageCount());
|
||||
after.setActualGrossWeightKg(request.actualGrossWeightKg());
|
||||
after.setActualVolumeCbm(request.actualVolumeCbm());
|
||||
if (repository.updateActualCargo(after, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity saved = required(id);
|
||||
addHistory(id, before.getStatus(), saved.getStatus(), action, request.reason(), before, saved, operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(saved));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
String action = "DELETE_DRAFT";
|
||||
@@ -247,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提交");
|
||||
@@ -280,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
|
||||
@@ -292,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());
|
||||
}
|
||||
@@ -349,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) {
|
||||
@@ -432,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 {
|
||||
|
||||
+1
-6
@@ -63,7 +63,6 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
""")
|
||||
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
/** 到港与日历统一取最新观测的实际记录,后续修正为更早时间时不能被历史最大值覆盖。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||
@@ -71,7 +70,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
current.actual_at AS latestActualAt,
|
||||
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
|
||||
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
||||
MAX(arrival.actual_at) AS arrivedDestinationPortAt,
|
||||
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
||||
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
||||
@@ -82,10 +81,6 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
|
||||
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
|
||||
LEFT JOIN fl_carrier_schedule_node_fact arrival ON arrival.id=(
|
||||
SELECT a.id FROM fl_carrier_schedule_node_fact a
|
||||
WHERE a.container_booking_id=b.id AND a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL
|
||||
ORDER BY a.observed_at DESC,a.id DESC LIMIT 1)
|
||||
WHERE b.id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
|
||||
|
||||
+3
-14
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,19 +82,15 @@ public class ContainerService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 首次关联已放舱订舱,仅规范化柜号并保留关联资格与并发校验。 */
|
||||
@Transactional
|
||||
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
|
||||
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
|
||||
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
|
||||
String existingContainerNo = trim(container.getContainerNo());
|
||||
String requestedContainerNo = trim(request.containerNo());
|
||||
// 本关联入口不按 ISO 长度、结构或校验位阻断,仍统一空白与大小写以核对唯一性。
|
||||
if (requestedContainerNo != null) {
|
||||
requestedContainerNo = trim(requestedContainerNo.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
|
||||
.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
String containerNo = requestedContainerNo == null ? existingContainerNo : requestedContainerNo;
|
||||
String containerNo = requestedContainerNo == null
|
||||
? existingContainerNo
|
||||
: Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
if (existingContainerNo != null && !existingContainerNo.equals(containerNo)) {
|
||||
throw conflict("输入的真实集装箱号与当前柜次已录入柜号不一致,请先在预装运输信息中纠正");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -493,15 +493,10 @@ public class LoadingListService {
|
||||
return repository.preloadDetail(requirePlan(planId));
|
||||
}
|
||||
|
||||
/** 保留关联入口已接受的柜号,运输入口首次录入或纠错仍执行原有校验。 */
|
||||
private static String targetContainerNo(AggregateRow before, String requested) {
|
||||
if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null;
|
||||
String normalized = requested.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
|
||||
.toUpperCase(java.util.Locale.ROOT);
|
||||
if (StringUtils.hasText(normalized) && normalized.equals(before.getContainerNo())) {
|
||||
return before.getContainerNo();
|
||||
}
|
||||
return Iso6346ContainerNumber.require(requested);
|
||||
if (requested != null) return Iso6346ContainerNumber.require(requested);
|
||||
return StringUtils.hasText(before.getContainerNo())
|
||||
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
|
||||
}
|
||||
|
||||
private static String transportHash(long planId, int version, String containerNo,
|
||||
|
||||
@@ -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;
|
||||
@@ -140,7 +137,6 @@ public class LoadingPickService {
|
||||
smartLoadingRepository.items(plan.getId()));
|
||||
}
|
||||
|
||||
/** 确认预装前验证作业资格和真实柜号,格式问题必须在实物作业前纠正。 */
|
||||
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
|
||||
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
|
||||
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
|
||||
@@ -152,7 +148,6 @@ public class LoadingPickService {
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw conflict("确认预装前必须录入真实集装箱号");
|
||||
}
|
||||
requireContainerNumberBeforeStarting(booking.getContainerNo());
|
||||
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
|
||||
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
|
||||
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
||||
@@ -166,14 +161,12 @@ public class LoadingPickService {
|
||||
return links;
|
||||
}
|
||||
|
||||
/** 仅在柜号已满足完成事件要求时创建 PDA 任务,已有任务的重复同步保持原语义。 */
|
||||
@Transactional
|
||||
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
||||
List<LoadingPlanItemEntity> planItems) {
|
||||
inboundHoldGuard.requirePlanNotHeld(planId);
|
||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||
if (task == null) {
|
||||
requireContainerNumberBeforeStarting(containerNo);
|
||||
task = new LoadingPickTaskEntity();
|
||||
task.setLoadingPlanId(planId);
|
||||
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
|
||||
@@ -559,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);
|
||||
}
|
||||
@@ -606,24 +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 static void requireContainerNumberBeforeStarting(String containerNo) {
|
||||
try {
|
||||
Iso6346ContainerNumber.require(containerNo);
|
||||
} catch (BizException exception) {
|
||||
throw conflict("请先在预装运输信息中纠正真实集装箱号后再确认预装或启动PDA,需符合ISO 6346");
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成前复核真实柜号,以生成一致的跨服务装柜完成事件。 */
|
||||
private void requireCompletionContainerNumber(long taskId) {
|
||||
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
@@ -636,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);
|
||||
@@ -648,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();
|
||||
@@ -711,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()));
|
||||
}
|
||||
@@ -773,14 +750,6 @@ public class LoadingPickService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查点失去当前有效租约时立即停止,不再推进后续客户或柜次。 */
|
||||
private void recordCompletionCheckpoint(long commandId, String worker, long tenantId, long inboundId,
|
||||
int sequence, String stage) {
|
||||
if (!completionCommands.checkpoint(commandId, worker, tenantId, inboundId, sequence, stage)) {
|
||||
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
|
||||
String requestHash, long operatorId) {
|
||||
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|
||||
@@ -795,18 +764,11 @@ public class LoadingPickService {
|
||||
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
|
||||
}
|
||||
|
||||
/** 记录实际阶段耗时,区分业务处理与调度、前端刷新等待。 */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-19
@@ -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,
|
||||
|
||||
-2
@@ -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,46 +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.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
|
||||
|
||||
@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;订舱关联不限制ISO格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
|
||||
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
|
||||
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
|
||||
@@ -100,7 +100,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK
|
||||
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
|
||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
|
||||
@@ -226,6 +228,11 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
|
||||
CHECK (record_status IN ('ACTIVE','MERGED'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_iso6346') THEN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
|
||||
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
|
||||
|
||||
@@ -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: 内部身份缺失、错误或服务端未配置}
|
||||
|
||||
@@ -118,9 +118,9 @@ paths:
|
||||
x-permission: platform:firstleg:customs:list
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
|
||||
get:
|
||||
summary: 平台下载预录单
|
||||
summary: 平台下载报关行回执
|
||||
x-permission: platform:firstleg:customs:list
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
|
||||
@@ -607,7 +607,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
summary: 将内部模拟柜关联到已发布订舱需求
|
||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仅移除空白并转大写,不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。关联接受的柜号在运输信息未修改柜号时不重复执行ISO校验;确认预装和创建PDA任务前仍校验ISO格式,并提示在预装运输信息中纠错,防止实物作业后才发现无法完成。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||
@@ -2947,7 +2947,7 @@ components:
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
|
||||
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
|
||||
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。关联时移除格式空白并转大写,不校验ISO长度、结构或校验位,仍保证同一订舱内唯一;确认预装及创建PDA任务前需纠正为符合ISO格式的真实柜号}
|
||||
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和同一订舱内唯一性}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
ContainerFeeAdjustRequest:
|
||||
type: object
|
||||
|
||||
@@ -50,7 +50,7 @@ paths:
|
||||
summary: 修改预装运输信息
|
||||
description: >-
|
||||
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
|
||||
containerNo 为 null 或规范化后等于当前值时保留关联入口已接受的柜号,不重复校验格式;首次录入或纠正为不同值时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
|
||||
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
|
||||
x-permission: platform:firstleg:preload:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
|
||||
@@ -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,22 +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}/estimated-arrival:
|
||||
put:
|
||||
summary: 放舱后维护预计到港
|
||||
description: 仅允许RELEASED订舱按当前版本与幂等键维护estimatedArrivalAt,不得清空或早于预计开船时间;其他运输、预报、关系方、柜型与附件仍冻结,保存前后快照及操作人。不修改实际到港或计划到仓。
|
||||
x-permission: platform:firstleg:ocean-booking:edit
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/EstimatedArrivalRequest'}}}}
|
||||
responses: {'200': {description: 已保存的RELEASED订舱详情,包含当前版本和预计到港}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
|
||||
put:
|
||||
summary: Supplement manual actual cargo on a RELEASED booking
|
||||
description: Only actual package count, gross weight and volume may change. Transport and declared cargo remain frozen. Requires expectedVersion, Idempotency-Key and a reason; saves before/after audit snapshots atomically. Null means not recorded, never zero or a fallback to declared cargo.
|
||||
x-permission: platform:firstleg:ocean-booking:edit
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualCargoRequest'}}}}
|
||||
responses: {'200': {description: Updated RELEASED detail}, '409': {description: State or version conflict}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Validate and permanently release a DRAFT
|
||||
@@ -105,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:
|
||||
@@ -113,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:
|
||||
@@ -208,40 +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: 统一货重上限}}}
|
||||
EstimatedArrivalRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [expectedVersion, estimatedArrivalAt]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
estimatedArrivalAt: {type: string, format: date-time}
|
||||
ActualCargoRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [expectedVersion, reason]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
reason: {type: string, minLength: 1, maxLength: 1000}
|
||||
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
|
||||
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
||||
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
||||
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
||||
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]}
|
||||
@@ -355,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: 平台订舱号;按北京时间创建日逐日编号}
|
||||
|
||||
+2
-3
@@ -88,7 +88,7 @@ class ContainerControllerContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAPI声明平台级货柜路径、关联柜号格式放行及冻结规则")
|
||||
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
|
||||
void documentsContainerContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
@@ -106,8 +106,7 @@ class ContainerControllerContractTest {
|
||||
.contains("未满配 requirementId")
|
||||
.contains("containerNo 可空")
|
||||
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
||||
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
|
||||
.contains("确认预装和创建PDA任务前仍校验ISO格式")
|
||||
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
|
||||
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
||||
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
||||
.doesNotContain("ContainerTransportUpdateRequest")
|
||||
|
||||
-17
@@ -4,7 +4,6 @@ import com.baiyue.firstleg.customs.container.controller.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
@@ -13,22 +12,6 @@ import java.lang.reflect.Method;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerCustomsControllerContractTest {
|
||||
/** 下载契约必须跟随实际 Controller,避免前端和文档共同固化不存在的地址。 */
|
||||
@Test void platformPreEntryDownloadMatchesActualControllerAndOpenApi() throws Exception {
|
||||
Method download = java.util.Arrays.stream(PlatformContainerCustomsController.class.getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals("downloadCompletion")).findFirst().orElseThrow();
|
||||
String root = PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||
String path = root + download.getAnnotation(GetMapping.class).value()[0];
|
||||
String source = new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
||||
java.util.Map<?, ?> specification = new Yaml().load(source);
|
||||
java.util.Map<?, ?> paths = (java.util.Map<?, ?>) specification.get("paths");
|
||||
|
||||
assertThat(path).isEqualTo("/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download");
|
||||
assertThat(paths.containsKey(path)).isTrue();
|
||||
assertThat(paths.containsKey(root + "/documents/{documentId}/receipt/download")).isFalse();
|
||||
assertThat(download.getAnnotation(PreAuthorize.class).value()).contains("platform:firstleg:customs:list");
|
||||
}
|
||||
|
||||
@Test void platformUsesListForReadsAndManageForWrites() {
|
||||
assertThat(PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/platform/v1/customs/loading-plans");
|
||||
|
||||
+2
-38
@@ -442,7 +442,6 @@ class ContainerCustomsServiceTest {
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 上传被拒绝时使用预录单名称,并保留报关行执行后的替换门禁。 */
|
||||
@Test void receiptCannotBeReplacedAfterBrokerSubmission(){
|
||||
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("broker-sent-key"),anyString())).thenReturn(1);
|
||||
@@ -451,10 +450,7 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.now());
|
||||
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
|
||||
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","broker-sent-key",0,file))
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error).hasMessage("客户确认的预录单已发送给报关行,不可再修改预录单");
|
||||
});
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不可再修改回执");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@@ -516,43 +512,11 @@ class ContainerCustomsServiceTest {
|
||||
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
/** 下载不可见或缺失的预录单时使用正式名称,不改变客户范围校验。 */
|
||||
@Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){
|
||||
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
|
||||
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("预录单不存在");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 平台下载的当前预录单不存在时仍返回原业务错误码和正式名称。 */
|
||||
@Test void platformMissingPreEntryDocumentUsesCurrentName() {
|
||||
when(mapper.document(1L)).thenReturn(document(1L));
|
||||
when(mapper.completion(1L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.downloadCompletion(0L, 1L, true))
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
assertThat(error).hasMessage("预录单不存在");
|
||||
});
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 发送前置校验仅调整用户可见名称,不改变全买单或客户确认门禁。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"0,0,全买单柜无需发送预录单确认结果给报关行", "1,1,仍有预录单未完成客户确认"})
|
||||
void brokerSubmissionErrorsUsePreEntryDocumentName(int taxRefundCount, int unconfirmedCount, String message) {
|
||||
when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_TO_BROKER_9"), eq("pre-entry-name"), anyString())).thenReturn(1);
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3));
|
||||
when(mapper.taxRefundCount(9L)).thenReturn(taxRefundCount);
|
||||
when(mapper.unconfirmedReceiptCount(2L)).thenReturn(unconfirmedCount);
|
||||
|
||||
assertThatThrownBy(() -> service.submitToBroker(9L, 5L, "admin", "pre-entry-name", new ActionRequest(3, true, null)))
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error).hasMessage(message);
|
||||
});
|
||||
verify(mapper, never()).submitToBroker(anyLong(), anyInt(), anyLong(), anyString());
|
||||
.hasMessageContaining("报关行回执不存在");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
|
||||
-50
@@ -14,37 +14,6 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformOceanBookingControllerContractTest {
|
||||
/** 预计到港命令仅接收版本与时间,沿用编辑权限及独立审计。 */
|
||||
@Test void estimatedArrivalCommandHasNarrowDtoAndEditPermission() throws Exception {
|
||||
var endpoint = method("updateEstimatedArrival");
|
||||
assertThat(endpoint.getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()).containsExactly("/{oceanBookingId}/estimated-arrival");
|
||||
assertThat(endpoint.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:firstleg:ocean-booking:edit')");
|
||||
assertThat(endpoint.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
assertThat(Arrays.stream(com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest.class.getRecordComponents())
|
||||
.map(java.lang.reflect.RecordComponent::getName)).containsExactly("expectedVersion", "estimatedArrivalAt");
|
||||
assertThat(Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")))
|
||||
.contains("/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:", "#/components/schemas/EstimatedArrivalRequest");
|
||||
}
|
||||
|
||||
/** 真实方法权限拦截器拒绝只有读取权限的账号,命令不会进入服务层。 */
|
||||
@Test void estimatedArrivalRejectsReadOnlyAccountBeforeServiceCall() {
|
||||
var service = org.mockito.Mockito.mock(com.baiyue.firstleg.oceanbooking.service.OceanBookingService.class);
|
||||
var target = new PlatformOceanBookingController();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(target, "service", service);
|
||||
var factory = new org.springframework.aop.framework.ProxyFactory(target);
|
||||
factory.setProxyTargetClass(true);
|
||||
factory.addAdvisor(org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize());
|
||||
var secured = (PlatformOceanBookingController) factory.getProxy();
|
||||
var context = org.springframework.security.core.context.SecurityContextHolder.getContext();
|
||||
context.setAuthentication(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("reader", "",
|
||||
java.util.List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("platform:firstleg:ocean-booking:list"))));
|
||||
try {
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> secured.updateEstimatedArrival(null, 1L, "eta-denied",
|
||||
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest(3, java.time.LocalDateTime.now())))
|
||||
.isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
|
||||
org.mockito.Mockito.verifyNoInteractions(service);
|
||||
} finally { org.springframework.security.core.context.SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test
|
||||
void usesDedicatedResourceAndPermissions() {
|
||||
assertThat(PlatformOceanBookingController.class.getAnnotation(RequestMapping.class).value())
|
||||
@@ -63,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"));
|
||||
@@ -79,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())
|
||||
|
||||
-17
@@ -14,13 +14,6 @@ import java.util.Arrays;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingMapperContractTest {
|
||||
/** 放舱后预计到港只更新该字段及版本审计,不允许带出其他冻结资料。 */
|
||||
@Test void estimatedArrivalUpdateIsNarrowAndVersioned() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateEstimatedArrival", long.class,
|
||||
java.time.LocalDateTime.class, int.class, long.class, String.class).getAnnotation(Update.class).value());
|
||||
assertThat(sql).contains("estimated_arrival_at=#{estimatedArrivalAt}", "status='RELEASED'", "version=#{version}", "version=version+1")
|
||||
.doesNotContain("vessel_name=", "voyage_no=", "estimated_sailing_at=", "actual_", "declared_", "warehouse_id=");
|
||||
}
|
||||
@Test
|
||||
void listUsesPlatformAggregateFilters() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectPage",
|
||||
@@ -53,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",
|
||||
|
||||
+3
-11
@@ -10,14 +10,6 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingSqlContractTest {
|
||||
/** 两份触发器基线只放开预计到港,继续冻结船名、开船、预报和其他运输资料。 */
|
||||
@Test void releasedFreezeAllowsEstimatedArrivalOnly() throws Exception {
|
||||
for (String source : new String[] {sql(), Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))}) {
|
||||
assertThat(source).doesNotContain("NEW.estimated_arrival_at <=> OLD.estimated_arrival_at")
|
||||
.contains("NEW.vessel_name <=> OLD.vessel_name", "NEW.estimated_sailing_at <=> OLD.estimated_sailing_at",
|
||||
"NEW.declared_package_count <=> OLD.declared_package_count", "NEW.version = OLD.version + 1");
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void definesDedicatedAggregateWithoutChangingContainerSemantics() throws Exception {
|
||||
String sql = sql();
|
||||
@@ -35,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'")
|
||||
@@ -57,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")
|
||||
|
||||
+1
-34
@@ -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);
|
||||
|
||||
+5
-154
@@ -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,142 +386,6 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
/** 预计到港独立更新、回显并保留修改前后审计,不能触及其他冻结字段。 */
|
||||
@Test void maintainsReleasedEstimatedArrivalWithVersionAndAudit() {
|
||||
var before = completeBooking("RELEASED", 3);
|
||||
var after = objectMapper.convertValue(before, OceanBookingEntity.class);
|
||||
var arrival = before.getEstimatedSailingAt().plusDays(20);
|
||||
after.setEstimatedArrivalAt(arrival); after.setVersion(4);
|
||||
when(repository.find(1L)).thenReturn(before, after);
|
||||
when(repository.updateEstimatedArrival(1L, arrival, 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
var result = service.updateEstimatedArrival(1L, 9L, "admin", "eta-save", new EstimatedArrivalRequest(3, arrival));
|
||||
|
||||
assertThat(result.oceanBooking().getEstimatedArrivalAt()).isEqualTo(arrival);
|
||||
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
|
||||
assertThat(result.oceanBooking().getVesselName()).isEqualTo(before.getVesselName());
|
||||
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ESTIMATED_ARRIVAL");
|
||||
assertThat(history.getValue().getBeforeSnapshot()).contains("estimatedArrivalAt");
|
||||
assertThat(history.getValue().getAfterSnapshot()).contains("estimatedArrivalAt");
|
||||
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
|
||||
verify(repository, never()).replaceRequirements(anyLong(), any());
|
||||
}
|
||||
|
||||
/** 草稿、旧版本、空值及逆序时间均不能走放舱后的预计到港命令。 */
|
||||
@Test void rejectsInvalidEstimatedArrivalCommands() {
|
||||
var value = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(value);
|
||||
var arrival = value.getEstimatedSailingAt().plusDays(20);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-draft", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
|
||||
value.setStatus("RELEASED");
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-stale", new EstimatedArrivalRequest(2, arrival))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-empty", new EstimatedArrivalRequest(3, null))).hasMessageContaining("不能为空");
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-early", new EstimatedArrivalRequest(3, value.getEstimatedSailingAt().minusSeconds(1)))).hasMessageContaining("不能早于");
|
||||
when(repository.updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any())).thenReturn(0);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-conflict", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 同一预计到港命令重放复用原结果,不重复保存或追加审计。 */
|
||||
@Test void estimatedArrivalReplayDoesNotWriteAgain() throws Exception {
|
||||
var request = new EstimatedArrivalRequest(3, LocalDateTime.of(2026, 10, 25, 10, 0));
|
||||
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_ESTIMATED_ARRIVAL", 1L, request));
|
||||
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
|
||||
when(repository.claimOperation(1L, 9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay", operation.getRequestHash())).thenReturn(false);
|
||||
when(repository.operation(9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay")).thenReturn(operation);
|
||||
assertThat(service.updateEstimatedArrival(1L, 9L, "admin", "eta-replay", request).oceanBooking().getVersion()).isEqualTo(4);
|
||||
verify(repository, never()).updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any());
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
@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; })
|
||||
@@ -561,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() {
|
||||
@@ -569,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) {
|
||||
|
||||
+3
-7
@@ -44,9 +44,8 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留批量读取,实际到港与日历采用相同的最新观测顺序。 */
|
||||
@Test
|
||||
void batchesActualContainerFactsWithLatestObservedArrivalAndWithoutEstimatedTimes() throws Exception {
|
||||
void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception {
|
||||
String sql = String.join("\n", CarrierScheduleQueryMapper.class
|
||||
.getMethod("selectContainerActualFacts", List.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
@@ -56,16 +55,13 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
.contains("f.actual_at IS NOT NULL")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
|
||||
.contains("MAX(arrival.actual_at) AS arrivedDestinationPortAt")
|
||||
.contains("a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL")
|
||||
.contains("ORDER BY a.observed_at DESC,a.id DESC LIMIT 1")
|
||||
.doesNotContain("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
|
||||
.contains("ORDER BY b.id")
|
||||
.doesNotContain("estimated_at");
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(5);
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3);
|
||||
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
+2
-14
@@ -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);
|
||||
|
||||
+3
-47
@@ -8,23 +8,12 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("货柜数据库映射契约")
|
||||
class ContainerBookingMapperContractTest {
|
||||
@@ -117,7 +106,7 @@ class ContainerBookingMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("初始化与统一增量保留柜号权威及订舱内唯一,不限制关联柜号ISO格式")
|
||||
@DisplayName("初始化与统一增量锁定真实柜号权威、订舱内唯一和ISO结构约束")
|
||||
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
|
||||
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
@@ -129,41 +118,8 @@ class ContainerBookingMapperContractTest {
|
||||
assertThat(source.toLowerCase())
|
||||
.contains("unique key uk_container_booking_ocean_container (ocean_booking_id,container_no)")
|
||||
.contains("key idx_container_booking_container_no (container_no,updated_at,id)")
|
||||
.doesNotContain("chk_container_booking_iso6346", "^[a-z]{3}[ujz][0-9]{7}$");
|
||||
.contains("constraint chk_container_booking_iso6346")
|
||||
.contains("^[a-z]{3}[ujz][0-9]{7}$");
|
||||
}
|
||||
}
|
||||
|
||||
/** 从当前建表基线创建隔离数据库,真实执行关联 Mapper 并验证唯一键和版本。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void linksNonIsoContainerNumbersThroughActualSchemaAndMapper(String containerNo) throws Exception {
|
||||
var dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:container-link-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
String source = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
int start = source.indexOf("CREATE TABLE IF NOT EXISTS fl_container_booking (");
|
||||
jdbc.execute(source.substring(start, source.indexOf(';', source.indexOf(") ENGINE=InnoDB", start)) + 1));
|
||||
for (long id = 1; id <= 3; id++) {
|
||||
jdbc.update("INSERT INTO fl_container_booking(id,cabinet_no,warehouse_id,warehouse_name,container_type,created_by,updated_by) VALUES(?,?,8,'测试仓','40HQ',9,9)",
|
||||
id, "D" + id);
|
||||
}
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(ContainerBookingMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
var repository = new ContainerRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", session.getMapper(ContainerBookingMapper.class));
|
||||
var booking = new OceanBookingEntity();
|
||||
|
||||
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT container_no FROM fl_container_booking WHERE id=1", String.class)).isEqualTo(containerNo);
|
||||
assertThat(jdbc.queryForObject("SELECT version FROM fl_container_booking WHERE id=1", Integer.class)).isEqualTo(1);
|
||||
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isZero();
|
||||
assertThatThrownBy(() -> repository.linkReleasedBooking(2L, 21L, 31L, containerNo, booking, 0, 9L, "操作员"))
|
||||
.isInstanceOf(DuplicateKeyException.class);
|
||||
assertThat(repository.linkReleasedBooking(3L, 22L, 32L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
-190
@@ -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();
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -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");
|
||||
|
||||
@@ -329,21 +329,17 @@ class ContainerServiceTest {
|
||||
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
/** 关联入口允许非 ISO 柜号,继续按原版本和关联范围保存。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
@DisplayName("首次关联不因柜号长度结构或校验位阻断")
|
||||
void linksWithoutIsoContainerNumberValidation(String containerNo) {
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝错误ISO 6346校验位")
|
||||
void rejectsInvalidContainerNumberCheckDigit() {
|
||||
stubLinkContext();
|
||||
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
|
||||
|
||||
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, containerNo, 2));
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
|
||||
|
||||
verify(repository).containerNoExists(21L, containerNo, 11L);
|
||||
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
|
||||
any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-103
@@ -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() {
|
||||
|
||||
@@ -26,9 +26,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -150,42 +147,10 @@ class LoadingListServiceTest {
|
||||
verifyNoInteractions(loadingPickService);
|
||||
}
|
||||
|
||||
/** 只更新航次时,关联已接受的非 ISO 柜号不应被重新校验或改写。 */
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@ValueSource(strings = {"LOCAL-1", " local-1 "})
|
||||
void preservesLinkedNonIsoNumberWhenOnlyVoyageChanges(String requested) {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
before.setVoyageNo("001E");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
when(repository.updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator")).thenReturn(1);
|
||||
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "preserve-linked-number",
|
||||
new TransportUpdateRequest(requested, "023E", 3, null));
|
||||
|
||||
verify(repository).updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator");
|
||||
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
|
||||
}
|
||||
|
||||
/** 非关联入口新录入或纠正柜号仍使用原有规则,不放宽到所有录入场景。 */
|
||||
@Test
|
||||
void stillRejectsNewNonIsoNumberInTransportCorrection() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "invalid-correction",
|
||||
new TransportUpdateRequest("OTHER-2", null, 3, "纠错")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
/** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */
|
||||
@Test
|
||||
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
before.setContainerNo("MSCU6639870");
|
||||
AggregateRow after = row("EDITING", 4);
|
||||
after.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
|
||||
@@ -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)));
|
||||
@@ -227,7 +220,7 @@ class LoadingPickServiceTest {
|
||||
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
|
||||
10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
|
||||
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
booking.setId(40L); booking.setContainerNo("MSCU6639870");
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-001");
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity();
|
||||
@@ -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());
|
||||
}
|
||||
@@ -962,57 +954,6 @@ class LoadingPickServiceTest {
|
||||
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
/** 关联放行的非 ISO 柜号必须在可纠错阶段明确反馈,纠正后可继续确认。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void rejectsNonIsoNumberBeforeConfirmationAndAllowsCorrection(String containerNo) {
|
||||
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
|
||||
10L, 40L, containerNo, "EDITING", 3, 2, null);
|
||||
var booking = new ContainerBookingEntity();
|
||||
booking.setId(40L);
|
||||
booking.setContainerNo(containerNo);
|
||||
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
|
||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||
|
||||
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
|
||||
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
|
||||
verify(completionCommands, never()).insert(any());
|
||||
|
||||
booking.setContainerNo("MSCU6639870");
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity();
|
||||
ocean.setId(90L);
|
||||
ocean.setStatus("RELEASED");
|
||||
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
|
||||
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
|
||||
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
|
||||
}
|
||||
|
||||
/** 任务创建入口同样先校验格式,不能绕过确认门禁进入实物作业。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void rejectsNonIsoNumberBeforeCreatingPdaTask(String containerNo) {
|
||||
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
|
||||
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
|
||||
verify(repository, never()).insert(any(LoadingPickItemEntity.class));
|
||||
}
|
||||
|
||||
/** 完成前的最终校验仍保留,不能让异常柜号生成无法投递的装柜完成事件。 */
|
||||
@Test
|
||||
void completionStillRequiresIsoNumberForCrossServiceFacts() {
|
||||
repository.activeBookingByTask(20L).setContainerNo("MSCU6639871");
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task());
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "invalid-final-container", "checked"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
verify(completionCommands, never()).insert(any());
|
||||
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOperationIdIsRejectedImmediatelyWithConflict() {
|
||||
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
|
||||
@@ -1056,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());
|
||||
}
|
||||
@@ -1150,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");
|
||||
}
|
||||
@@ -1170,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());
|
||||
@@ -1357,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());
|
||||
@@ -1386,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());
|
||||
|
||||
-48
@@ -2,7 +2,6 @@ package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
@@ -11,53 +10,6 @@ import java.time.LocalDateTime;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ContainerCalendarSourceMapperTest {
|
||||
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
|
||||
@Test
|
||||
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,query_id BIGINT,container_booking_id BIGINT," +
|
||||
"standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
|
||||
sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " +
|
||||
"(1,'D1','2026-10-29 08:00:00','ACTIVE'),(2,'D2','2026-10-29 08:00:00','ACTIVE')");
|
||||
sql.execute("INSERT INTO fl_carrier_schedule_query VALUES(1,0,'READY'),(2,0,'READY')");
|
||||
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
|
||||
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES " +
|
||||
"(1,1,1,'ARRIVED_DESTINATION_PORT','2026-10-23 14:00:00','2026-10-24 14:00:00')," +
|
||||
"(2,1,1,'ARRIVED_DESTINATION_PORT','2026-10-21 14:00:00','2026-10-25 14:00:00')," +
|
||||
"(3,1,1,'ARRIVED_DESTINATION_PORT',NULL,'2026-10-26 14:00:00')," +
|
||||
"(4,2,2,'ARRIVED_DESTINATION_PORT','2026-10-24 14:00:00','2026-10-25 14:00:00')," +
|
||||
"(5,2,2,'ARRIVED_DESTINATION_PORT','2026-10-22 14:00:00','2026-10-25 14:00:00')");
|
||||
}
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(ContainerCalendarSourceMapper.class);
|
||||
config.addMapper(CarrierScheduleQueryMapper.class);
|
||||
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var from = LocalDateTime.parse("2026-10-01T00:00:00");
|
||||
var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
|
||||
var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L));
|
||||
assertEquals(2, calendar.size());
|
||||
var explicit = session.getMapper(ContainerCalendarSourceMapper.class).byIds(java.util.List.of(1L,2L));
|
||||
assertEquals(calendar,explicit);
|
||||
assertEquals(2, workbench.size());
|
||||
for (int index = 0; index < calendar.size(); index++) {
|
||||
var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00");
|
||||
assertEquals(expected, calendar.get(index).arrivalAt());
|
||||
assertEquals("ACTUAL", calendar.get(index).arrivalType());
|
||||
assertEquals(expected, workbench.get(index).arrivedDestinationPortAt());
|
||||
assertEquals(calendar.get(index).arrivalAt().plusDays(2).toLocalDate(), workbench.get(index).arrivedDestinationPortAt().plusDays(2).toLocalDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
|
||||
@Test
|
||||
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
| 贴标申请、箱唛/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` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.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容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
||||
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实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,8 +7,8 @@
|
||||
退货预报、签收、库存、合箱与退货出库:
|
||||
|
||||
- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。
|
||||
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量。确认必须传提交前全单累计实收量 `expectedReceivedQuantity`,在订单锁内校验;响应丢失后换新键重复提交旧快照返回冲突,刷新后重新操作,同键幂等请求不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
|
||||
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。实物合箱 `/cartons/merge-boxes` 必须提交扫码时全部来源箱 SKU 数量 `expectedSourceQuantities`,来源行锁内核对完整快照;旧快照换新键也拒绝扣减,重新扫描后才能提交当前实物。内部退货换标调用从已锁定任务及来源库存生成快照。
|
||||
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
|
||||
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。
|
||||
- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。
|
||||
- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。
|
||||
|
||||
@@ -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 |
|
||||
@@ -97,7 +97,7 @@
|
||||
- 平台一件代发出库候选由 `/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 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
|
||||
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。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 及其他消费者仍由门禁拒绝。
|
||||
|
||||
@@ -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.'));
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ public interface AppointmentForecastClient {
|
||||
record Request(List<Long> containerBookingIds) { }
|
||||
record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference,
|
||||
LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { }
|
||||
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis,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);
|
||||
}
|
||||
|
||||
-11
@@ -16,12 +16,6 @@ public interface AppointmentWorkDispatchPort {
|
||||
record CandidateBatches(List<BatchReference> appointmentBatches,List<String> blockReasons,boolean occupied) { }
|
||||
/** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */
|
||||
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
|
||||
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
|
||||
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items);
|
||||
record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages,
|
||||
String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { }
|
||||
record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount,
|
||||
List<AllocationOption> options,List<String> blockReasons) { }
|
||||
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
|
||||
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
|
||||
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
|
||||
@@ -31,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) { }
|
||||
/** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */
|
||||
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);
|
||||
}
|
||||
|
||||
+6
-13
@@ -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 >= #{from} AND c.plannedAt < #{to})
|
||||
OR (c.plannedDate >= DATE(#{from}) AND c.plannedDate < DATE(#{to}))
|
||||
<if test="ids != null and !ids.isEmpty()">
|
||||
OR c.sourceContainerBookingId IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ public final class WarehouseCalendarModels {
|
||||
private WarehouseCalendarModels() { }
|
||||
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
|
||||
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
|
||||
public record Entry(String sourceContainerBookingId, String 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) { }
|
||||
}
|
||||
|
||||
+4
-14
@@ -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) {}
|
||||
}
|
||||
+2
-12
@@ -59,26 +59,16 @@ public class DeliveryCargoGroups {
|
||||
var rows=entry.getValue().stream().sorted(Comparator.comparing(CargoLine::dispatchItemId)
|
||||
.thenComparing(CargoLine::attributionId)).toList();
|
||||
var first=rows.get(0);
|
||||
// FBA以真实仓码分组;外接来货可缺内部仓ID,只沿同组已知ID核对,不能覆盖冲突值。
|
||||
var destination=rows.stream().map(CargoLine::destination)
|
||||
.filter(value->!blank(value.warehouseId())).findFirst().orElse(first.destination());
|
||||
if (rows.stream().anyMatch(row->!Objects.equals(withKnownFbaWarehouseId(row.destination(),destination.warehouseId()),destination)))
|
||||
if (rows.stream().anyMatch(row->!Objects.equals(row.destination(),first.destination())))
|
||||
throw conflict("同组货物的目的地信息不一致,请核对后安排");
|
||||
long packages=rows.stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact);
|
||||
long pallets=rows.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count();
|
||||
result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),destination,
|
||||
result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),first.destination(),
|
||||
hash(rows),rows,packages,pallets));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 仅补同一分组FBA缺失的内部引用,其他目的地事实必须保持完全一致。 */
|
||||
private Destination withKnownFbaWarehouseId(Destination value,String warehouseId) {
|
||||
if (!"FBA".equals(value.type()) || !blank(value.warehouseId()) || blank(warehouseId)) return value;
|
||||
return new Destination(value.key(),value.type(),warehouseId,value.warehouseCode(),value.warehouseName(),
|
||||
value.address(),value.postalCode(),value.contactName(),value.contactPhone());
|
||||
}
|
||||
|
||||
/** 客户是组身份的一部分,同目的仓的其他客户永不合单。 */
|
||||
public String groupKey(String customerId,Destination destination) {
|
||||
return hash(List.of(customerId,destination.key()));
|
||||
|
||||
-18
@@ -53,18 +53,6 @@ public class DispatchAggregateController {
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Items> items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 本车货物逐票读取正式预约选项及当前分配。 */
|
||||
@GetMapping("/{dispatchNo}/appointment-allocations")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocations> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.appointmentAllocations(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 原车版本化关联正式预约,不新建排车或修改库存。 */
|
||||
@PutMapping("/{dispatchNo}/appointment-allocations")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<CommandResult> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocationCommand request) {
|
||||
return ApiResponse.success(service.saveAppointmentAllocations(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 读取实际起点和卸货顺序。 */
|
||||
@GetMapping("/{dispatchNo}/route")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
@@ -89,12 +77,6 @@ public class DispatchAggregateController {
|
||||
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
|
||||
return ApiResponse.success(loadingDeparture.complete(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));
|
||||
}
|
||||
/** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */
|
||||
@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')")
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 平台排车装车资料入口,与另一端共用五框和自动派送门禁。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
|
||||
public class DispatchLoadingDocumentController {
|
||||
private final DispatchLoadingDocumentService service;
|
||||
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
|
||||
public DispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
|
||||
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.get(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
|
||||
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
|
||||
@PutMapping("/{slot}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@PathVariable Slot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(DispatchActorFactory.platform(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 原上传仅允许当前有权限的原操作者恢复。 */
|
||||
@GetMapping("/uploads/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.recover(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
|
||||
@GetMapping("/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) {
|
||||
var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(value.content());
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -36,7 +36,6 @@ import java.nio.charset.StandardCharsets;
|
||||
public class DispatchQueryController {
|
||||
@Resource private DispatchAggregateService aggregateService;
|
||||
@Resource private DispatchBolPrintService printService;
|
||||
@Resource private com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
@@ -48,14 +47,6 @@ public class DispatchQueryController {
|
||||
return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query));
|
||||
}
|
||||
|
||||
/** 内部需求池沿独立只读权限,不授予销售整车客户及司机资料读取权限。 */
|
||||
@GetMapping("/capacity-demands")
|
||||
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Demand>> capacityDemands(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) {
|
||||
requirePlatform(user); return ApiResponse.success(capacityDemands.page(query));
|
||||
}
|
||||
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
@Operation(summary = "Get dispatch details and all pallet destinations")
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ public class SmartDispatchController {
|
||||
/** 读取实际候选,不在草稿域复制来源筛选逻辑。 */
|
||||
@GetMapping("/candidates")
|
||||
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
|
||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String warehouseId,
|
||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
|
||||
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
|
||||
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken,
|
||||
@RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) {
|
||||
|
||||
-3
@@ -9,8 +9,6 @@ public class DispatchAggregate {
|
||||
private Long id;
|
||||
private String dispatchNo;
|
||||
private String taskType;
|
||||
/** 是否向内部散板需求池发布本车余位。 */
|
||||
private Boolean publishLooseboardDemand = true;
|
||||
private Long tripId;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
@@ -26,7 +24,6 @@ public class DispatchAggregate {
|
||||
private String dispatchReviewJson;
|
||||
private String loadingReviewJson;
|
||||
private String resourcesJson;
|
||||
private String exceptionsJson;
|
||||
private String vehiclePlate;
|
||||
private String route;
|
||||
private LocalDateTime plannedDepartureAt;
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
|
||||
public interface DispatchEvidenceGateway {
|
||||
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION }
|
||||
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO }
|
||||
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
|
||||
/** 上传只写本车、本类证据。 */
|
||||
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
|
||||
|
||||
+2
-2
@@ -83,9 +83,9 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
|
||||
|| !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
|
||||
return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes());
|
||||
}
|
||||
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
|
||||
/** 司机本人照片只接受图片;车辆和驾驶证沿现有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));
|
||||
return TYPES.contains(Objects.toString(contentType,"")) && (kind!=Kind.DRIVER_PHOTO || !"application/pdf".equals(contentType));
|
||||
}
|
||||
/** 无效证据不能作为已核实资料。 */
|
||||
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
|
||||
|
||||
+6
-30
@@ -19,20 +19,7 @@ public interface DispatchAggregateMapper {
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId})
|
||||
OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId})
|
||||
)</if>
|
||||
<if test='q.fbaAppointmentNo != null'> AND (
|
||||
EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.destination_type='FBA'
|
||||
AND JSON_CONTAINS(COALESCE(JSON_EXTRACT(d.resources_json,'$.maintenance.destinationAppointments'),'[]'),
|
||||
JSON_OBJECT('destinationKey',CONCAT('FBA:',UPPER(TRIM(COALESCE(NULLIF(i.destination_warehouse_code,''),i.fba_warehouse_code)))),'appointmentNo',#{q.fbaAppointmentNo}))=1)
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a
|
||||
WHERE a.dispatch_id=d.id AND a.batch_key LIKE 'FBA_APPOINTMENT:%' AND
|
||||
""" + DispatchReceivingSql.CONFIRMATION_NO + """
|
||||
=#{q.fbaAppointmentNo}
|
||||
AND NOT EXISTS(SELECT 1 FROM JSON_TABLE(
|
||||
COALESCE(JSON_EXTRACT(d.resources_json,'$.maintenance.destinationAppointments'),'[]'),
|
||||
'$[*]' COLUMNS(destination_key VARCHAR(256) PATH '$.destinationKey',
|
||||
appointment_no VARCHAR(128) PATH '$.appointmentNo' NULL ON EMPTY)) saved
|
||||
WHERE saved.destination_key=a.destination_key AND NULLIF(TRIM(saved.appointment_no),'') IS NOT NULL))
|
||||
)</if>
|
||||
<if test='q.fbaAppointmentNo != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo})</if>
|
||||
""";
|
||||
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
|
||||
String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END";
|
||||
@@ -53,12 +40,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},
|
||||
@@ -79,17 +66,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);
|
||||
@@ -115,14 +91,14 @@ public interface DispatchAggregateMapper {
|
||||
|
||||
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
|
||||
@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}
|
||||
""")
|
||||
|
||||
+3
-5
@@ -39,7 +39,7 @@ public interface DispatchBolMapper {
|
||||
LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id
|
||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=di.customer_id
|
||||
AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER'
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=a.plan_id
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=p.plan_id
|
||||
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id
|
||||
LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=l.customer_id
|
||||
WHERE di.dispatch_order_id=#{dispatchOrderId}
|
||||
@@ -49,13 +49,11 @@ public interface DispatchBolMapper {
|
||||
""")
|
||||
List<Cargo> findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId);
|
||||
|
||||
/** 原batch+客户+稳定来源+目的地定位货物,编号取当前客户收货确认;BOOKING保留持久化order_id。 */
|
||||
/** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */
|
||||
@Select("""
|
||||
SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,
|
||||
a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey,
|
||||
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,
|
||||
""" + DispatchReceivingSql.CONFIRMATION_NO + """
|
||||
AS confirmationNo
|
||||
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo
|
||||
FROM ll_dispatch_appointment_allocation a
|
||||
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
|
||||
LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id
|
||||
|
||||
+1
-2
@@ -50,8 +50,7 @@ public interface DispatchCandidatePageMapper {
|
||||
}
|
||||
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
|
||||
private static String filtered(boolean count) {
|
||||
return " FROM ("+rows(count)+") candidates WHERE 1=1"
|
||||
+"<if test='query.warehouseId != null'> AND overseasWarehouseId=#{query.warehouseId}</if>"
|
||||
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
|
||||
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
|
||||
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
|
||||
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
|
||||
|
||||
-10
@@ -11,16 +11,6 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
|
||||
/** 收货只定位原计划已关联的有效排车,按车辆父锁顺序返回。 */
|
||||
@Select("<script>SELECT DISTINCT d.dispatch_no,d.trip_id,d.id FROM ll_dispatch_order d "
|
||||
+ "JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id "
|
||||
+ "WHERE d.status='ACTIVE' AND i.plan_id IN "
|
||||
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
|
||||
+ "ORDER BY d.trip_id,d.id</script>")
|
||||
List<ReceiptDispatch> receiptDispatches(@Param("ids") List<Long> planIds);
|
||||
|
||||
record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { }
|
||||
|
||||
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
|
||||
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
|
||||
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
|
||||
-18
@@ -55,24 +55,6 @@ public interface DispatchReadMapper {
|
||||
""")
|
||||
List<BatchRow> batches(@Param("id") long dispatchId);
|
||||
|
||||
record CargoShipment(String customerId, String destinationKey, String shipmentNo) { }
|
||||
/** 逐票实际货物决定货件号;手填预约号或没有正式批次引用也能读取,客户严格随来源关联。 */
|
||||
@Select("""
|
||||
SELECT DISTINCT i.customer_id AS customerId,
|
||||
CONCAT('FBA:',UPPER(TRIM(COALESCE(NULLIF(TRIM(l.fba_warehouse_code),''),NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),''))))) AS destinationKey,
|
||||
CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.fba_shipment_no ELSE d.shipment_no END AS shipmentNo
|
||||
FROM ll_dispatch_item i
|
||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id
|
||||
AND i.return_receipt_id IS NULL AND COALESCE(i.source_business_type,'')<>'RELABEL_ORDER'
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=i.customer_id
|
||||
JOIN ll_order o ON o.id=CASE WHEN l.id IS NULL THEN i.order_id ELSE l.order_id END AND o.customer_id=i.customer_id
|
||||
LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id
|
||||
AND UPPER(TRIM(d.destination_warehouse_code))=UPPER(TRIM(COALESCE(NULLIF(TRIM(l.fba_warehouse_code),''),NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),''))))
|
||||
LEFT JOIN ll_warehouse_transfer_order wt ON wt.lastleg_order_id=o.id AND wt.customer_id=o.customer_id
|
||||
WHERE i.dispatch_order_id=#{id} AND i.destination_type='FBA'
|
||||
""")
|
||||
List<CargoShipment> cargoShipmentNumbers(@Param("id") long dispatchId);
|
||||
|
||||
/** 未接通跨客户批次写提供者前必须阻断静默丢批次,不能写无依据运输。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
/** 排车列表和BOL共用当前客户收货确认;调用方的a必须是逐票预约分配。 */
|
||||
final class DispatchReceivingSql {
|
||||
/** 只读当前有效覆盖,不使用多客户批次头汇总值,历史退出记录不能提供编号。 */
|
||||
static final String CONFIRMATION_NO = """
|
||||
(SELECT rg.confirmation_no
|
||||
FROM ll_fba_receiving_member rm
|
||||
JOIN ll_fba_receiving_agreement rg ON rg.agreement_key=rm.agreement_key
|
||||
WHERE rm.batch_key=a.batch_key AND rm.customer_id=a.customer_id AND rm.active_ref IS NOT NULL
|
||||
AND rg.destination_key=a.destination_key AND rg.status='ACTIVE'
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(rg.snapshot_json,'$.details.appointmentStatus'))='SUCCESS'
|
||||
AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(rg.snapshot_json,'$.details.appointmentAt')),'null') IS NOT NULL)
|
||||
""";
|
||||
|
||||
/** 仅提供Mapper编译期SQL片段。 */
|
||||
private DispatchReceivingSql() { }
|
||||
}
|
||||
-23
@@ -21,24 +21,6 @@ public interface DispatchSourceMapper {
|
||||
record OrderReference(Long orderId,String customerId) { }
|
||||
record ItemPicked(Long itemId,long packages) { }
|
||||
|
||||
/** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */
|
||||
@Select("""
|
||||
SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId,
|
||||
p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo,
|
||||
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
|
||||
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
|
||||
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage
|
||||
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
|
||||
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
||||
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||
""")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
|
||||
|
||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
|
||||
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
|
||||
@@ -111,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);
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ 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));
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 绑定仓PDA装车资料入口,与另一端共用五框和自动派送门禁。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
|
||||
public class PdaDispatchLoadingDocumentController {
|
||||
private final DispatchLoadingDocumentService service;
|
||||
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
|
||||
public PdaDispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
|
||||
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.get(DispatchActorFactory.pda(user),dispatchNo));
|
||||
}
|
||||
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
|
||||
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
|
||||
@PutMapping("/{slot}")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@PathVariable Slot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(DispatchActorFactory.pda(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 原上传仅允许当前有权限的原操作者恢复。 */
|
||||
@GetMapping("/uploads/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.recover(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
|
||||
@GetMapping("/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) {
|
||||
var value=service.content(DispatchActorFactory.pda(user),dispatchNo,slot,fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(value.content());
|
||||
}
|
||||
}
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** 内部余位池仅公开本车运输摘要,不包含客户货物或司机私人资料。 */
|
||||
public final class DispatchCapacityDemands {
|
||||
/** 只承载只读查询模型。 */
|
||||
private DispatchCapacityDemands() { }
|
||||
public record Query(Integer page, Integer size, String origin, String destination) {
|
||||
/** 查询范围有界,默认每页二十条。 */
|
||||
public Query {
|
||||
page = page == null ? 1 : page; size = size == null ? 20 : size;
|
||||
if (page < 1 || size < 1 || size > 100) throw new IllegalArgumentException("余位需求分页参数无效");
|
||||
origin = origin == null || origin.isBlank() ? null : origin.trim();
|
||||
destination = destination == null || destination.isBlank() ? null : destination.trim();
|
||||
}
|
||||
}
|
||||
public record Demand(String dispatchNo, String warehouseName, String route, Instant plannedDepartureAt,
|
||||
Integer vehiclePalletSpaces, Integer scheduledPalletCount, Integer remainingPalletSpaces,
|
||||
BigDecimal remainingWeightKg, BigDecimal remainingVolumeCbm, boolean capacityKnown, String capacityReason) { }
|
||||
}
|
||||
+5
-15
@@ -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,
|
||||
@@ -69,20 +66,13 @@ public final class DispatchCommands {
|
||||
public Pick { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
/** 按服务端已核平的扫码托盘确认装车,不要求现场重复测量或补填箱数。 */
|
||||
/** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */
|
||||
public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash,
|
||||
String remark) {
|
||||
List<LoadingMeasurement> measurements, String remark) {
|
||||
/** 后台与PDA共用必填版本约束。 */
|
||||
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
|
||||
public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
|
||||
ReturnDirection returnDirection, String description, List<Long> imageFileIds) {
|
||||
/** 必填版本用于阻止过期页面覆盖当前排车。 */
|
||||
public MarkException { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
public record Depart(Long recordVersion, String idempotencyKey, String remark) {
|
||||
/** 实际发车不得接受缺省版本。 */
|
||||
public Depart { recordVersion = requireVersion(recordVersion); }
|
||||
|
||||
+2
-57
@@ -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,52 +34,7 @@ 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, List<DestinationAppointment> destinationAppointments) {
|
||||
/** 空白统一为空;只约束已填写的选项及长度,不阻止空草稿保存。 */
|
||||
public Maintenance {
|
||||
deliveryMethod = option(deliveryMethod, "派送方式", "FBA自提", "客户自提", "卡派打托", "卡派地板", "快递", "外配");
|
||||
pickupMethod = option(pickupMethod, "提卸方式", "一提一卸", "一提多卸");
|
||||
loadType = option(loadType, "装载类型", "托盘", "地板", "混装");
|
||||
vehicleType = option(vehicleType, "车辆类型", "厢式货车", "平板车", "集装箱卡车");
|
||||
fleetEmail = limited(fleetEmail, "车队邮箱", 254);
|
||||
appointmentAccount = limited(appointmentAccount, "预约账号", 128);
|
||||
if (destinationAppointments != null && destinationAppointments.stream().anyMatch(java.util.Objects::isNull)) throw new IllegalArgumentException("预约目的仓不能空缺");
|
||||
destinationAppointments = destinationAppointments == null ? List.of() : List.copyOf(destinationAppointments);
|
||||
if (destinationAppointments.size() > 500 || destinationAppointments.stream().map(DestinationAppointment::destinationKey).distinct().count() != destinationAppointments.size())
|
||||
throw new IllegalArgumentException("同一目的仓只能填写一个预约号");
|
||||
}
|
||||
/** 选项按正式文案保存,拒绝未知值而不猜测业务含义。 */
|
||||
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 DestinationAppointment(String destinationKey, String appointmentNo) {
|
||||
/** 草稿允许暂缺编号,目的仓身份及文本长度仍须明确。 */
|
||||
public DestinationAppointment {
|
||||
destinationKey = destinationKey == null ? null : destinationKey.trim();
|
||||
appointmentNo = appointmentNo == null || appointmentNo.isBlank() ? null : appointmentNo.trim();
|
||||
if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4 || destinationKey.length() > 256)
|
||||
throw new IllegalArgumentException("预约目的仓无效");
|
||||
if (appointmentNo != null && appointmentNo.length() > 128) throw new IllegalArgumentException("预约号最多128字符");
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
-29
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,23 +32,19 @@ public final class DispatchViews {
|
||||
public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
|
||||
ReviewState reviewState, Reviews reviews, Resources resources,
|
||||
String warehouseId, String warehouseCode, String warehouseName,
|
||||
Instant createdAt, Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
||||
Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
||||
boolean pickingReconciled, boolean arranged,
|
||||
Map<Action, Gate> actions, List<FbaAppointment> fbaAppointments) { }
|
||||
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,
|
||||
String status, Instant confirmedAt, String blockReason, String customerId, List<String> shipmentNumbers) { }
|
||||
String status, Instant confirmedAt, String blockReason) { }
|
||||
|
||||
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) { }
|
||||
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,
|
||||
|
||||
@@ -14,14 +14,6 @@ public final class DispatchWorkModels {
|
||||
public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List<CargoItem> items) { }
|
||||
public record Stop(int index,String name,String address,String destinationKey) { }
|
||||
public record Route(String dispatchNo,long recordVersion,List<Stop> stops,Gate edit) { }
|
||||
public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo,
|
||||
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.AllocationSource> sources,List<BatchAllocation> allocations) { }
|
||||
public record AppointmentAllocations(String dispatchNo,long recordVersion,List<AppointmentItem> items,Gate edit) { }
|
||||
/** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */
|
||||
public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List<Long> dispatchItemIds,List<BatchAllocation> batchAllocations) {
|
||||
/** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */
|
||||
public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);}
|
||||
}
|
||||
/** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */
|
||||
public record RouteCommand(Long recordVersion,String idempotencyKey,List<String> destinationKeys,String reason) {
|
||||
/** 显式零版本合法,缺版本不能覆盖并发变更。 */
|
||||
|
||||
-8
@@ -34,18 +34,12 @@ 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); }
|
||||
/** 全搜索基集的阶段和审核分布。 */
|
||||
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
|
||||
/** 按整车读取全部真实客户明细。 */
|
||||
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
|
||||
/** 只读取本次确认入库的原计划关联排车。 */
|
||||
public List<String> receiptDispatches(List<Long> planIds) {
|
||||
return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList();
|
||||
}
|
||||
/** 保存已核对真实客户与仓库的来源快照。 */
|
||||
public int insertItem(DispatchItem item) { return items.insert(item); }
|
||||
/** 未执行明细才可移除,资格和根版本由应用层保证。 */
|
||||
@@ -65,8 +59,6 @@ public class DispatchAggregateRepository {
|
||||
public List<DispatchReadMapper.DeliveryRow> deliveries(long id) { return reads.deliveries(id); }
|
||||
/** 已有关联预约批次只读事实。 */
|
||||
public List<DispatchReadMapper.BatchRow> batches(long id) { return reads.batches(id); }
|
||||
/** 本车实际货物的权威货件号,不依赖预约分配,也不把重复托盘当作新货件。 */
|
||||
public List<DispatchReadMapper.CargoShipment> cargoShipmentNumbers(long id) { return reads.cargoShipmentNumbers(id); }
|
||||
/** 是否必须走受保护提供者核验批次分配。 */
|
||||
public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user