Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6088ff10c2 | ||
|
|
6d04659ced | ||
|
|
044812cd90 | ||
|
|
ae361879e5 | ||
|
|
c442c9eaa7 | ||
|
|
1ea7b4ff12 | ||
|
|
0976b6ef0d | ||
|
|
0c6c55f6f1 | ||
|
|
4a0e86bd42 |
Vendored
+33
-1
@@ -67,6 +67,8 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||
List<String> failedServices = []
|
||||
if (state.startsWith('RESULTS')) {
|
||||
String summary = state.substring('RESULTS'.length()).trim()
|
||||
int metadataIndex = summary.indexOf(' | ')
|
||||
if (metadataIndex >= 0) summary = summary.substring(0, metadataIndex).trim()
|
||||
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||
int separator = item.indexOf('=')
|
||||
if (separator > 0) {
|
||||
@@ -103,6 +105,25 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 测试范围必须相对成功门禁,而非失败/取消轮次;否则补跑可能遗漏前次未通过的变更。
|
||||
def previousSuccessfulTestCommit(def previousBuild, List<String> requiredServices) {
|
||||
def candidate = previousBuild
|
||||
int inspected = 0
|
||||
while (candidate != null && inspected < 30) {
|
||||
String description = candidate.description ?: ''
|
||||
String marker = ' | TESTS=PASSED '
|
||||
int markerIndex = description.lastIndexOf(marker)
|
||||
if (candidate.result == 'SUCCESS' && markerIndex >= 0) {
|
||||
List metadata = description.substring(markerIndex + marker.length()).trim().split(' ')
|
||||
if (metadata.size() == 2 && metadata[0] ==~ /[0-9a-fA-F]{7,40}/ &&
|
||||
requiredServices.every { metadata[1].split(',').contains(it) }) return metadata[0]
|
||||
}
|
||||
candidate = candidate.previousBuild
|
||||
inspected++
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
def environmentChoicesForJob(String jobBaseName) {
|
||||
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
||||
}
|
||||
@@ -161,6 +182,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: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
@@ -269,6 +291,7 @@ pipeline {
|
||||
selected = selected.unique()
|
||||
|
||||
env.SELECTED_SERVICES = selected.join(',')
|
||||
env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected)
|
||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||
if (!selected) {
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
@@ -335,16 +358,22 @@ pipeline {
|
||||
.findAll { it }
|
||||
.join(',')
|
||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||
String testPlan = "${pwd(tmp: true)}/ci-test-plan.json"
|
||||
lock(resource: 'firstleg-test-maven') {
|
||||
timeout(time: 50, unit: 'MINUTES') {
|
||||
sh """
|
||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||
node deploy/scripts/ci-test-scope.mjs plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}'
|
||||
node deploy/scripts/ci-test-scope.mjs checks --plan '${testPlan}'
|
||||
test_arguments=\"\$(node deploy/scripts/ci-test-scope.mjs args --plan '${testPlan}')\"
|
||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test \$test_arguments
|
||||
node deploy/scripts/ci-test-scope.mjs verify --plan '${testPlan}'
|
||||
"""
|
||||
}
|
||||
}
|
||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||
env.TEST_GATE_PASSED = 'true'
|
||||
}
|
||||
}
|
||||
post {
|
||||
@@ -401,6 +430,9 @@ pipeline {
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
||||
: "${env.IMAGE_TAG} | ${summary}"
|
||||
if (env.TEST_GATE_PASSED == 'true') {
|
||||
currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}"
|
||||
}
|
||||
if (failed) {
|
||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'
|
||||
import { resolve, relative, basename } from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const moduleName = 'lastleg-service'
|
||||
const testClass = /(?:^Test.+|.+(?:Test|Tests|TestCase))\.java$/
|
||||
const words = source => new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
|
||||
const tables = source => new Set(source.match(/\bll_[a-z0-9_]+\b/g) || [])
|
||||
const writtenTables = source => new Set([...source.matchAll(/\b(?:UPDATE|INSERT\s+(?:IGNORE\s+)?INTO|DELETE\s+FROM)\s+(ll_[a-z0-9_]+)/gi)].map(match => match[1]))
|
||||
|
||||
// 按完整注解比较 SQL,避免只改 SELECT 时把同一 Mapper 内未改的全部写语句扩大为全域回归。
|
||||
function writeStatements(source) {
|
||||
const result = []
|
||||
for (const match of source.matchAll(/@(?:Insert|Update|Delete)\s*\(/g)) {
|
||||
let depth = 1, quote = '', index = match.index + match[0].length
|
||||
for (; index < source.length && depth; index++) {
|
||||
if (quote) {
|
||||
if (source.startsWith(quote, index)) { index += quote.length - 1; quote = '' }
|
||||
else if (source[index] === '\\' && quote.length === 1) index++
|
||||
} else if (source.startsWith('"""', index)) { quote = '"""'; index += 2 }
|
||||
else if (source[index] === '"' || source[index] === "'") quote = source[index]
|
||||
else if (source[index] === '(') depth++
|
||||
else if (source[index] === ')') depth--
|
||||
}
|
||||
if (depth) throw new Error('Cannot parse SQL annotation; use MODULE test scope')
|
||||
result.push(source.slice(match.index, index))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function changedWrites(node) {
|
||||
if (node.previousSource === undefined) return writtenTables(node.source)
|
||||
const before = writeStatements(node.previousSource), after = writeStatements(node.source)
|
||||
const changed = [...before.filter(sql => !after.includes(sql)), ...after.filter(sql => !before.includes(sql))]
|
||||
const oldRest = before.reduce((text, sql) => text.replace(sql, ''), node.previousSource)
|
||||
const newRest = after.reduce((text, sql) => text.replace(sql, ''), node.source)
|
||||
if (oldRest !== newRest) changed.push(oldRest, newRest)
|
||||
return writtenTables(changed.join('\n'))
|
||||
}
|
||||
|
||||
/** 宁可扩大范围,不用文件名猜测唯一测试;未识别的输入保留完整模块门禁。 */
|
||||
export function planTests({ modules, mode = 'AUTO', changes = [], sources = [], baseline = '' }) {
|
||||
const architectureScript = `${moduleName}/scripts/analyze-lastleg-dependencies`
|
||||
const staticChecks = changes.some(path => path.startsWith(architectureScript))
|
||||
? [`${architectureScript}.test.mjs`] : []
|
||||
const full = reason => ({ scope: 'MODULE', reason, modules, baseline, tests: [], staticChecks })
|
||||
if (mode !== 'AUTO' && mode !== 'MODULE') throw new Error('TEST_SCOPE must be AUTO or MODULE')
|
||||
if (mode === 'MODULE') return full('显式完整模块回归')
|
||||
if (!baseline) return full('没有成功测试基线')
|
||||
if (modules.length !== 1 || modules[0] !== moduleName) return full('多服务或尚未核对的服务采用完整模块回归')
|
||||
const prefix = `${moduleName}/`
|
||||
const relevant = changes.filter(path => path.startsWith(prefix))
|
||||
const shared = changes.some(path => path !== 'scripts/agents-index-contract.ps1' && !path.startsWith(prefix)
|
||||
&& /^(?:common-[^/]+\/|platform-bom\/|pom\.xml$|Jenkinsfile|deploy\/|config\/|scripts\/.*\.(?:mjs|sh|ps1)$)/.test(path))
|
||||
if (shared) return full('公共能力、发布或测试基础设施变更')
|
||||
if (!relevant.length) return full('补跑服务没有可验证的业务差异')
|
||||
const java = `${prefix}src/main/java/com/baiyue/lastleg/`
|
||||
const supported = path => path.startsWith(`${prefix}src/test/java/`)
|
||||
|| path.startsWith(`${prefix}src/main/resources/openapi/`)
|
||||
|| path.startsWith(architectureScript)
|
||||
|| (path.startsWith(java) && path.slice(java.length).includes('/')
|
||||
&& !/^(?:config|security|entity|dto|model|mapper|repository|service|client)\//.test(path.slice(java.length)))
|
||||
|| /\.(?:md)$/.test(path)
|
||||
if (relevant.some(path => !supported(path))) return full('共享模型、配置、SQL、资源或未识别路径变更')
|
||||
|
||||
const nodes = sources.map(({ path, source, previousSource }) => ({ path, source, previousSource,
|
||||
symbol: basename(path, '.java'), tokens: words(source), tables: tables(source),
|
||||
test: path.startsWith(`${prefix}src/test/java/`),
|
||||
name: `${source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''}.${basename(path, '.java')}` }))
|
||||
const byPath = new Map(nodes.map(node => [node.path, node]))
|
||||
const bySymbol = new Map()
|
||||
for (const node of nodes) {
|
||||
if (!bySymbol.has(node.symbol)) bySymbol.set(node.symbol, [])
|
||||
bySymbol.get(node.symbol).push(node)
|
||||
}
|
||||
const reverse = new Map(nodes.map(node => [node.path, new Set()]))
|
||||
for (const node of nodes) {
|
||||
// 包含反射字符串、静态 import、同包引用及测试夹具;重名类全部纳入。
|
||||
for (const token of node.tokens) for (const dependency of bySymbol.get(token) || []) {
|
||||
if (dependency !== node) reverse.get(dependency.path).add(node.path)
|
||||
}
|
||||
// 实现变化也要覆盖只依赖接口/基类的调用者,不能仅沿显式 import 反查。
|
||||
for (const match of node.source.matchAll(/\b(?:implements|extends)\s+([^{};]+)/g)) {
|
||||
for (const token of words(match[1])) for (const contract of bySymbol.get(token) || []) {
|
||||
if (contract !== node) reverse.get(node.path).add(contract.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
const seeds = new Set()
|
||||
const changedTables = new Set()
|
||||
for (const path of relevant.filter(path => path.endsWith('.java'))) {
|
||||
const node = byPath.get(path)
|
||||
if (node) {
|
||||
seeds.add(path)
|
||||
// 读查询变化不改变其他查询的数据;潜在写入变化则纳入同表读写消费者。
|
||||
if (!node.test) for (const table of changedWrites(node)) changedTables.add(table)
|
||||
} else {
|
||||
// 删除/移动旧类型时,仍按旧类名查现存消费者。
|
||||
const symbol = basename(path, '.java')
|
||||
const consumers = nodes.filter(consumer => consumer.tokens.has(symbol))
|
||||
if (!consumers.length) return full('删除类型缺少可追踪的现存消费者')
|
||||
for (const consumer of consumers) seeds.add(consumer.path)
|
||||
}
|
||||
}
|
||||
if (!seeds.size) return full('没有可追踪的 Java 行为差异')
|
||||
for (const node of nodes) if ([...node.tables].some(table => changedTables.has(table))) seeds.add(node.path)
|
||||
const affected = new Set(seeds)
|
||||
const queue = [...seeds]
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
for (const consumer of reverse.get(queue[index]) || []) if (!affected.has(consumer)) {
|
||||
affected.add(consumer); queue.push(consumer)
|
||||
}
|
||||
}
|
||||
const allTests = nodes.filter(node => node.test && testClass.test(basename(node.path))
|
||||
&& /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/.test(node.source))
|
||||
const contractChange = relevant.some(path => path.startsWith(`${prefix}src/main/resources/openapi/`))
|
||||
const candidates = allTests.filter(node => affected.has(node.path)
|
||||
|| /(?:Security|Isolation|MapperRegistrationArchitecture|QueryBinding)/.test(node.symbol)
|
||||
|| (contractChange && /(?:Contract|OpenApi|Schema|Architecture|Permission)/.test(node.symbol)))
|
||||
// 显式依赖真实环境的类仍由 MODULE/专项授权入口负责,不能把它们默认跳过算作执行成功。
|
||||
const optIn = node => /@(?:EnabledIf(?:SystemProperty|EnvironmentVariable)|Disabled(?:IfSystemProperty|IfEnvironmentVariable)?\b)/
|
||||
.test(node.source.slice(0, node.source.indexOf(`class ${node.symbol}`)))
|
||||
const tests = candidates.filter(node => !optIn(node))
|
||||
if (!tests.some(node => affected.has(node.path))) return full('没有匹配到行为测试,拒绝零测试通过')
|
||||
if (tests.some(node => !/^com\.[\w.$]+$/.test(node.name))) return full('测试类名无法安全传入 Maven')
|
||||
return { scope: 'AFFECTED', reason: 'Java 反向引用、接口实现、测试夹具、共享 SQL 表和相关静态合同',
|
||||
modules, baseline, tests: tests.map(node => node.name).sort(), totalTests: allTests.length, staticChecks,
|
||||
optInTests: candidates.filter(optIn).map(node => node.name).sort() }
|
||||
}
|
||||
|
||||
function walk(root) {
|
||||
const files = []
|
||||
for (const item of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = resolve(root, item.name)
|
||||
if (item.isDirectory()) files.push(...walk(path))
|
||||
else if (item.isFile() && item.name.endsWith('.java')) files.push(path)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/** 每个所选类必须有本轮实际执行的报告;允许上游零匹配不能变成整个门禁零测试。 */
|
||||
export function verifyReports(plan, reports) {
|
||||
if (plan.scope !== 'AFFECTED') return
|
||||
for (const name of plan.tests) {
|
||||
const matches = reports.filter(report => report.name === name || report.name.startsWith(`${name}$`))
|
||||
if (!matches.length || matches.some(report => report.failures || report.errors
|
||||
|| ['tests', 'skipped', 'failures', 'errors'].some(key => !Number.isInteger(report[key]) || report[key] < 0)
|
||||
|| report.skipped > report.tests)
|
||||
|| matches.reduce((sum, report) => sum + report.tests - report.skipped, 0) <= 0) {
|
||||
throw new Error(`Missing, skipped or failed fresh test report: ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
const [action, ...options] = args
|
||||
const values = {}
|
||||
for (let i = 0; i < options.length; i += 2) {
|
||||
if (!options[i].startsWith('--') || options[i + 1] === undefined) throw new Error('Expected --name value')
|
||||
values[options[i].slice(2)] = options[i + 1]
|
||||
}
|
||||
const root = process.cwd()
|
||||
const planFile = values.plan
|
||||
if (!planFile) throw new Error('--plan is required')
|
||||
if (action === 'plan') {
|
||||
const modules = (values.modules || '').split(',')
|
||||
if (modules.some(module => !/^[a-z][a-z0-9-]+$/.test(module))) throw new Error('Invalid modules')
|
||||
let baseline = values.base || ''
|
||||
let changes = []
|
||||
if (baseline) {
|
||||
if (!/^[0-9a-f]{7,40}$/i.test(baseline)) throw new Error('Invalid baseline commit')
|
||||
try {
|
||||
execFileSync('git', ['merge-base', '--is-ancestor', baseline, 'HEAD'], { stdio: 'pipe' })
|
||||
changes = execFileSync('git', ['diff', '--name-only', '-z', baseline, 'HEAD'], { encoding: 'utf8' }).split('\0').filter(Boolean)
|
||||
} catch { baseline = '' }
|
||||
}
|
||||
const sources = modules.length === 1 && modules[0] === moduleName
|
||||
? ['main', 'test'].flatMap(set => walk(resolve(root, moduleName, 'src', set, 'java')))
|
||||
.map(path => {
|
||||
const key = relative(root, path).replaceAll('\\', '/')
|
||||
const entry = { path: key, source: readFileSync(path, 'utf8') }
|
||||
if (baseline && changes.includes(key)) {
|
||||
try { entry.previousSource = execFileSync('git', ['show', `${baseline}:${key}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) }
|
||||
catch { entry.previousSource = '' }
|
||||
}
|
||||
return entry
|
||||
}) : []
|
||||
const plan = { ...planTests({ modules, mode: values.mode || 'AUTO', baseline, changes, sources }), startedAt: Date.now() }
|
||||
writeFileSync(planFile, JSON.stringify(plan))
|
||||
console.log(`CI test scope: ${plan.scope}; ${plan.reason}`)
|
||||
if (plan.scope === 'AFFECTED') console.log(`Selected ${plan.tests.length}/${plan.totalTests} test classes:\n${plan.tests.join('\n')}`)
|
||||
if (plan.optInTests?.length) console.log(`Explicit environment tests remain opt-in (not counted as passed): ${plan.optInTests.join(',')}`)
|
||||
return
|
||||
}
|
||||
const plan = JSON.parse(readFileSync(planFile, 'utf8'))
|
||||
if (action === 'args') {
|
||||
if (plan.scope === 'AFFECTED') process.stdout.write(`-Dtest=${plan.tests.join(',')} -Dsurefire.failIfNoSpecifiedTests=false -Dci.sql-semantic.skip=true`)
|
||||
} else if (action === 'checks') {
|
||||
for (const check of plan.staticChecks || []) {
|
||||
if (check !== `${moduleName}/scripts/analyze-lastleg-dependencies.test.mjs`) throw new Error('Unsupported static check')
|
||||
execFileSync(process.execPath, ['--test', '--test-concurrency=2', check], { stdio: 'inherit' })
|
||||
}
|
||||
} else if (action === 'verify') {
|
||||
const reports = []
|
||||
if (plan.scope === 'AFFECTED') for (const module of plan.modules) {
|
||||
const directory = resolve(root, module, 'target/surefire-reports')
|
||||
for (const filename of readdirSync(directory)) {
|
||||
if (!/^TEST-.*\.xml$/.test(filename)) continue
|
||||
const path = resolve(directory, filename)
|
||||
if (statSync(path).mtimeMs < plan.startedAt - 2000) continue
|
||||
const xml = readFileSync(path, 'utf8').match(/<testsuite\b([^>]+)>/)?.[1]
|
||||
if (!xml) continue
|
||||
const attrs = Object.fromEntries([...xml.matchAll(/([\w-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]))
|
||||
reports.push({ name: attrs.name || '', ...Object.fromEntries(['tests', 'failures', 'errors', 'skipped'].map(key => [key, Number(attrs[key] || 0)])) })
|
||||
}
|
||||
}
|
||||
verifyReports(plan, reports)
|
||||
console.log(`CI test reports verified: ${plan.scope}`)
|
||||
} else throw new Error(`Unknown action: ${action}`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 }
|
||||
}
|
||||
@@ -77,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`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
||||
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
||||
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
|
||||
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
|
||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
|
||||
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
|
||||
|
||||
### 装车资料触发派送边界(2026-10-21)
|
||||
|
||||
`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。
|
||||
|
||||
### 外配业务原排车详情读取边界(2026-10-21)
|
||||
|
||||
`OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
|
||||
<artifactId>lastleg-service</artifactId>
|
||||
<properties>
|
||||
<!-- 定向 -Dtest 在 default-test 执行所选类,避免后置 SQL 执行再次运行同一列表。 -->
|
||||
<ci.sql-semantic.skip>false</ci.sql-semantic.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.5.3</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||
@@ -42,6 +46,7 @@
|
||||
<phase>test</phase>
|
||||
<goals><goal>test</goal></goals>
|
||||
<configuration>
|
||||
<skip>${ci.sql-semantic.skip}</skip>
|
||||
<includes><include>**/*SqlSemanticTest.java</include></includes>
|
||||
<reuseForks>false</reuseForks>
|
||||
</configuration>
|
||||
|
||||
@@ -72,11 +72,18 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|
||||
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|
||||
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
|
||||
'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService',
|
||||
'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController',
|
||||
'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source)
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort'
|
||||
|| [
|
||||
'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
|
||||
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
|
||||
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
|
||||
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
|
||||
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
|
||||
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
|
||||
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
|
||||
'dispatch must use fleet ports and guards, not its persistence mappers');
|
||||
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.dispatch.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 平台排车装车资料入口,与另一端共用五框和自动派送门禁。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
|
||||
public class DispatchLoadingDocumentController {
|
||||
private final DispatchLoadingDocumentService service;
|
||||
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
|
||||
public DispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
|
||||
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.get(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
|
||||
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
|
||||
@PutMapping("/{slot}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@PathVariable Slot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(DispatchActorFactory.platform(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 原上传仅允许当前有权限的原操作者恢复。 */
|
||||
@GetMapping("/uploads/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.recover(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
|
||||
@GetMapping("/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) {
|
||||
var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(value.content());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ public interface DispatchAggregateMapper {
|
||||
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
||||
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||
WHERE id=#{r.id} AND record_version=#{version}
|
||||
""")
|
||||
|
||||
+5
@@ -93,6 +93,11 @@ public interface DispatchSourceMapper {
|
||||
@Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE")
|
||||
Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id);
|
||||
|
||||
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
|
||||
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
|
||||
|
||||
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
||||
@Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE")
|
||||
Long lockReturn(@Param("customer") String customer, @Param("id") Long id);
|
||||
|
||||
+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
@@ -0,0 +1,60 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -25,7 +25,15 @@ public final class DispatchContracts {
|
||||
|
||||
/** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */
|
||||
public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn,
|
||||
List<Long> fileIds, Instant expectedTaskEndAt) { }
|
||||
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); }
|
||||
}
|
||||
|
||||
public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate,
|
||||
String carrierName, String vehicleStatus, Capacity capacity,
|
||||
|
||||
+2
@@ -74,6 +74,8 @@ public class DispatchSourceRepository {
|
||||
public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); }
|
||||
/** 锁定真实计划托盘根。 */
|
||||
public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); }
|
||||
/** 只读核对原计划已经结束实际收货。 */
|
||||
public boolean receivedPlanCompleted(String customer,Long id){return source.receivedPlanCompleted(customer,id)==1;}
|
||||
/** 锁定实际退回收据根。 */
|
||||
public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); }
|
||||
/** 真实库存数量及版本。 */
|
||||
|
||||
+41
-13
@@ -51,6 +51,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
|
||||
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks;
|
||||
|
||||
/** 领域编排只访问Repository和提供者应用入口。 */
|
||||
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
|
||||
@@ -273,6 +274,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
|
||||
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|
||||
|| !driver.evidence().fileIds().contains(fileId))return null;
|
||||
// 取货允许补传本车驾驶证,仅明确冻结的主档引用才走司机归属。
|
||||
if("PICKUP".equals(row.getTaskType()) && !Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
|
||||
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
|
||||
return driver.driverId();
|
||||
}
|
||||
@@ -304,7 +307,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L);
|
||||
row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L);
|
||||
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
|
||||
Resources resources=resources(shell); row.setResourcesJson(write(resources));
|
||||
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
||||
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
Resources resources=resources(shell,photos); row.setResourcesJson(write(resources));
|
||||
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
|
||||
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
|
||||
@@ -382,8 +387,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
String before=basis(row);
|
||||
// 本趟司机照片随资源维护保存,逐个核对本车及照片类别。
|
||||
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
||||
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
|
||||
row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
|
||||
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
|
||||
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
|
||||
@@ -398,7 +406,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
requireEditing(row,request.recordVersion());
|
||||
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||
verifyReviewResources(row);
|
||||
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
|
||||
if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核");
|
||||
long round=row.getReviewRound()+1;String basis=basis(row);
|
||||
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
|
||||
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null)));
|
||||
@@ -415,7 +423,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(actor!=null && actor.mode()==ActorMode.PDA) {
|
||||
if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action))
|
||||
throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核");
|
||||
required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true);
|
||||
lockReviewRoot(actor,number);row=required(actor,number,true);
|
||||
} else row=lockPlatform(actor,number);
|
||||
var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
@@ -540,7 +548,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing));
|
||||
}
|
||||
|
||||
/** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */
|
||||
/** 实载确认:扫码核平后保存装载证据并进入装车后,五张装车资料齐全再推进派送。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true);
|
||||
@@ -610,7 +618,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
}
|
||||
|
||||
/** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */
|
||||
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); }
|
||||
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); }
|
||||
/** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */
|
||||
private void lockReviewRoot(Actor actor,String number) {
|
||||
var row=required(actor,number,false);
|
||||
if("PICKUP".equals(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number);
|
||||
}
|
||||
/** 严格请求身份不接收前端摘要或操作者。 */
|
||||
private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));}
|
||||
/** 每次状态变更、历史及不可变命令结果同事务保存。 */
|
||||
@@ -627,7 +640,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */
|
||||
private String basis(DispatchAggregate row) {
|
||||
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts);
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
|
||||
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));}
|
||||
return hashes.source(facts);
|
||||
}
|
||||
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
|
||||
private void invalidate(DispatchAggregate row,String reason) {
|
||||
@@ -652,6 +668,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
|
||||
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
|
||||
void verifyReviewResources(DispatchAggregate row) {
|
||||
if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;}
|
||||
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
|
||||
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
|
||||
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
|
||||
@@ -659,9 +676,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
|
||||
if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
|
||||
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
|
||||
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
|
||||
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
|
||||
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
}
|
||||
/** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */
|
||||
private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) {
|
||||
@@ -848,8 +866,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
|
||||
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
|
||||
var blocked=new EnumMap<Action,Gate>(Action.class);
|
||||
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART)
|
||||
blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
|
||||
boolean pickup="PICKUP".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus());
|
||||
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) {
|
||||
boolean state=pickup && switch(action) {
|
||||
case EDIT,CANCEL -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage());
|
||||
case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState());
|
||||
case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState());
|
||||
case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState());
|
||||
default -> false;
|
||||
};
|
||||
boolean allowed=state && hasPermission(actor,action);
|
||||
blocked.put(action,new Gate(allowed,allowed?null:"TRANSPORT_TASK",allowed?null:"请按取货运输审核流程操作"));
|
||||
}
|
||||
return Collections.unmodifiableMap(blocked);
|
||||
}
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
@@ -940,12 +968,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */
|
||||
private static Resources resources(FleetExecutionModels.DispatchShell shell) {
|
||||
/** 合并Fleet权威资源与本趟司机照片,照片不写入司机主档。 */
|
||||
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos) {
|
||||
var v=shell.vehicle(); var d=shell.driver();
|
||||
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
|
||||
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt())));
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)));
|
||||
}
|
||||
|
||||
/** 主档选择不接受请求覆盖其车牌和容量。 */
|
||||
|
||||
+7
-2
@@ -19,7 +19,7 @@ import java.util.TreeMap;
|
||||
@Component
|
||||
public final class DispatchCommandHasher {
|
||||
private static final Set<String> UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services",
|
||||
"batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds");
|
||||
"batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds", "photoFileIds");
|
||||
private final ObjectMapper json;
|
||||
|
||||
/** 复用已注册的时间、数值和公开字段序列化配置。 */
|
||||
@@ -60,7 +60,12 @@ public final class DispatchCommandHasher {
|
||||
if (node == null || node.isNull()) return "null";
|
||||
if (node.isObject()) {
|
||||
var values = new TreeMap<String, String>();
|
||||
node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey())));
|
||||
node.fields().forEachRemaining(entry -> {
|
||||
// 新增可选照片为空时沿用旧摘要,保留历史命令重放与已通过审核的依据。
|
||||
if (entry.getKey().equals("photoFileIds") && (entry.getValue().isNull()
|
||||
|| entry.getValue().isArray() && entry.getValue().isEmpty())) return;
|
||||
values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()));
|
||||
});
|
||||
var result = new ArrayList<String>();
|
||||
values.forEach((key, value) -> result.add(quoted(key) + ":" + value));
|
||||
return "{" + String.join(",", result) + "}";
|
||||
|
||||
+28
-13
@@ -3,13 +3,14 @@ package com.baiyue.lastleg.dispatch.service;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */
|
||||
/** 装车与资料可并行完成,两项齐全时在当前事务自动推进派送。 */
|
||||
@Service
|
||||
public class DispatchLoadingDepartureService {
|
||||
private final DispatchAggregateService workflow;
|
||||
@@ -17,38 +18,52 @@ public class DispatchLoadingDepartureService {
|
||||
private final DispatchDepartureService departure;
|
||||
private final DispatchCommandLedger commands;
|
||||
private final DispatchCommandHasher hashes;
|
||||
private final DispatchLoadingPhotoPort documents;
|
||||
|
||||
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
|
||||
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery,
|
||||
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) {
|
||||
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes,
|
||||
DispatchLoadingPhotoPort documents) {
|
||||
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
|
||||
this.documents=documents;
|
||||
}
|
||||
|
||||
/** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */
|
||||
/** 原请求记录最终阶段;重放不重复装车、创建派送或发车。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
workflow.requireLoadingPermission(actor);
|
||||
var context=workflow.lockForArrangement(actor,number);
|
||||
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
|
||||
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
|
||||
delivery.departureFacts(context,true);
|
||||
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
|
||||
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
|
||||
context=workflow.preview(actor,number);
|
||||
String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE");
|
||||
var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
|
||||
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
|
||||
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
|
||||
finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false);
|
||||
commands.append(context.dispatchId(),actor,identity,result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult departAfterDocuments(Actor actor,String number,String key) {
|
||||
var context=workflow.lockForArrangement(actor,number);
|
||||
if(context.stage()!=Stage.AFTER_LOADING || context.status()!=Status.ACTIVE)
|
||||
throw new BizException(ResultCode.CONFLICT,"当前阶段不能按装车资料推进派送");
|
||||
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
|
||||
delivery.departureFacts(context,true);
|
||||
String arrangementKey=stepKey(number,key,"ARRANGE");
|
||||
var fact=delivery.prepareForDeparture(context,actor,arrangementKey);
|
||||
var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(),
|
||||
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
|
||||
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
|
||||
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"DEPART"),request.remark()));
|
||||
if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded))
|
||||
stepKey(number,key,"DEPART"),"五张装车照片已齐全,自动进入派送中"));
|
||||
if(departed.stage()!=Stage.DELIVERING)
|
||||
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
|
||||
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(),
|
||||
departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false);
|
||||
commands.append(context.dispatchId(),actor,identity,result);return result;
|
||||
return departed;
|
||||
}
|
||||
|
||||
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.util.*;
|
||||
|
||||
/** Admin/PDA按整车共用装车资料;通过车队公开端口保存照片,不读取其持久化。 */
|
||||
@Service
|
||||
public class DispatchLoadingDocumentService {
|
||||
public static final List<Slot> SLOTS=List.of(Slot.values());
|
||||
private final DispatchAggregateService workflow;
|
||||
private final DispatchLoadingDepartureService departure;
|
||||
private final DispatchLoadingPhotoPort photos;
|
||||
/** 装车资料与履约原子编排留在排车域,车队只提供照片事实。 */
|
||||
public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure,DispatchLoadingPhotoPort photos) {
|
||||
this.workflow=workflow;this.departure=departure;this.photos=photos;
|
||||
}
|
||||
/** GET只读,PDA每次按可信主体重验冻结仓。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Documents get(Actor actor,String number) {
|
||||
require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true));
|
||||
}
|
||||
/** 上传先记原请求意图,不提前绑定或推进派送。 */
|
||||
public Photo upload(Actor actor,String number,Slot slot,String key,MultipartFile file) {
|
||||
require(actor,true);var context=context(actor,number,false);return photos.upload(context.dispatchId(),actor.operatorId(),slot,key,file);
|
||||
}
|
||||
/** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */
|
||||
public Photo recover(Actor actor,String number,String key) {
|
||||
require(actor,true);var context=context(actor,number,false);return photos.recover(context.dispatchId(),actor.operatorId(),key);
|
||||
}
|
||||
/** 锁顺序为车次、排车、附加行;第五张绑定与派送发车共同提交或回滚。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) {
|
||||
require(actor,true);var context=context(actor,number,true);
|
||||
var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay;
|
||||
var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,request);
|
||||
if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) {
|
||||
departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number);
|
||||
}
|
||||
var result=documents(context,state,true);
|
||||
photos.recordBind(context.dispatchId(),actor.operatorId(),slot,request,result);return result;
|
||||
}
|
||||
/** 当前引用由照片提供者再次核验,拒绝未绑定或其他车文件。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Download content(Actor actor,String number,Slot slot,long fileId) {
|
||||
require(actor,false);var context=context(actor,number,false);return photos.content(context.dispatchId(),slot,fileId);
|
||||
}
|
||||
/** 装车中可同步上传;后续阶段仍须有真实装载证据,仓隔离沿用主流程。 */
|
||||
private Context context(Actor actor,String number,boolean lock) {
|
||||
var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number);
|
||||
if(value.status()!=Status.ACTIVE || !Set.of(Stage.LOADING,Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage())
|
||||
|| value.stage()!=Stage.LOADING && (value.loadingCompletedAt()==null || value.loadingCompletionHash()==null))
|
||||
throw new BizException(ResultCode.CONFLICT,"进入装车中后才能管理装车资料");
|
||||
return value;
|
||||
}
|
||||
/** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */
|
||||
private Documents documents(Context context,State state,boolean editable) {
|
||||
return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),context.stage().name(),editable,complete(state.photos()),state.photos());
|
||||
}
|
||||
/** 按五个固定框核齐,不能用总数或重复槽位代替。 */
|
||||
static boolean complete(List<Photo> photos) {
|
||||
return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank()));
|
||||
}
|
||||
/** 平台list与edit分离,PDA使用既有作业角色和绑定仓。 */
|
||||
private void require(Actor actor,boolean write) {
|
||||
if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限");
|
||||
}
|
||||
/** 权限取当前会话,不为PDA补造平台权限。 */
|
||||
private boolean allowed(Actor actor,boolean write) {
|
||||
var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false;
|
||||
var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList();
|
||||
return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate")
|
||||
&& authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")
|
||||
:authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list"));
|
||||
}
|
||||
}
|
||||
+6
-31
@@ -87,7 +87,7 @@ public class DispatchPdaQueryService {
|
||||
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
|
||||
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
|
||||
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
|
||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
|
||||
}
|
||||
@@ -95,36 +95,11 @@ public class DispatchPdaQueryService {
|
||||
}
|
||||
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
|
||||
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
|
||||
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return List.of();
|
||||
var actual=rows.get(0);
|
||||
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|
||||
|| !Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|
||||
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|
||||
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
|
||||
|| !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|
||||
|| !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of();
|
||||
var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of();
|
||||
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
|
||||
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
|
||||
|| n(selected.getPackageCount())!=member.packages())return List.of();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId())
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()))
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of();
|
||||
return stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList();
|
||||
var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
|
||||
if(received.isEmpty())return List.of();
|
||||
// 只修改本次投影对象,不在GET更新排车、版本或库存。
|
||||
item.setPackageCount(received.get().source().packageCount());
|
||||
return received.get().positions();
|
||||
}
|
||||
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||
|
||||
+33
-21
@@ -27,6 +27,7 @@ public class DispatchPickingService {
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet;
|
||||
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
|
||||
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
|
||||
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
|
||||
@@ -101,33 +102,44 @@ public class DispatchPickingService {
|
||||
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
|
||||
commands.append(row.getId(),actor,identity,result);return result;
|
||||
}
|
||||
/** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */
|
||||
/** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
|
||||
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
|
||||
long dispatch=row.getId();
|
||||
if(item.getWarehousePalletId()!=null) {
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
|
||||
}
|
||||
if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托");
|
||||
var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(orders.isEmpty())throw conflict("原计划缺少真实订单组成");
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0);
|
||||
if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对");
|
||||
if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单");
|
||||
item.setWarehousePalletId(actual.warehousePalletId());
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
|
||||
for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
|
||||
.ifPresent(fact->received.put(candidate,fact));
|
||||
if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
|
||||
// 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。
|
||||
for(var entry:received.entrySet()) {
|
||||
entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
|
||||
entry.getKey().setPackageCount(entry.getValue().source().packageCount());
|
||||
}
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对");
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true);
|
||||
var pallets=new ArrayList<Long>();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId());
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId());
|
||||
try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");}
|
||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存");
|
||||
for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
|
||||
|| !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
|
||||
}
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var candidate=entry.getKey();var actual=entry.getValue().source();
|
||||
try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");}
|
||||
candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存");
|
||||
}
|
||||
row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
}
|
||||
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
|
||||
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
|
||||
|
||||
/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */
|
||||
final class DispatchReceivedPlan {
|
||||
record Fact(DispatchPalletCandidate source, List<DispatchViews.Position> positions) { }
|
||||
|
||||
/** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */
|
||||
static Optional<Fact> resolve(String warehouse, DispatchItem item, List<DispatchItem> items,
|
||||
DispatchSourceRepository sources, DispatchService rules) {
|
||||
if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null
|
||||
|| !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty();
|
||||
if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return Optional.empty();
|
||||
var actual=rows.get(0);
|
||||
if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty();
|
||||
var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(original.isEmpty() || !new HashSet<>(original).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty();
|
||||
var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty();
|
||||
// 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。
|
||||
if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null)
|
||||
&& (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse)
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0
|
||||
|| p.allocatedPackages()!=0 || p.pickedPackages()!=0)
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty();
|
||||
return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList()));
|
||||
}
|
||||
|
||||
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
|
||||
static boolean sameIdentity(DispatchItem item,DispatchPalletCandidate actual) {
|
||||
return "RECEIVED".equals(actual.sourceStage()) && actual.warehousePalletId()!=null
|
||||
&& Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|
||||
&& Objects.equals(actual.customerId(),item.getCustomerId())
|
||||
&& Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
&& Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
&& Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|
||||
&& Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress());
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.*;
|
||||
|
||||
/** 取货审核照片按本车分类校验,驾驶证主档引用明确区分于本车补传。 */
|
||||
@Service
|
||||
public class TransportReviewPhotos {
|
||||
private final DispatchEvidenceGateway files;
|
||||
private final DriverService drivers;
|
||||
private final ObjectMapper json;
|
||||
/** 注入正式文件归属与司机资格入口。 */
|
||||
public TransportReviewPhotos(DispatchEvidenceGateway files, DriverService drivers, ObjectMapper json) {
|
||||
this.files=files; this.drivers=drivers; this.json=json;
|
||||
}
|
||||
/** 读取已保存资源;空排车还没有照片。 */
|
||||
public Resources resources(DispatchAggregate row) {
|
||||
if(row.getResourcesJson()==null)return new Resources(null,null);
|
||||
try{return json.readValue(row.getResourcesJson(),Resources.class);}
|
||||
catch(Exception e){throw new IllegalStateException("取货照片快照读取失败",e);}
|
||||
}
|
||||
/** 校验三类照片及主档身份,换车或换司机不能沿用旧对象的照片。 */
|
||||
public DriverEvidence validate(DispatchAggregate row, FleetTripEntity trip, TransportTaskModels.Assign request) {
|
||||
var photos=request.reviewPhotos();
|
||||
if(photos==null || photos.vehicleFileIds().isEmpty() || photos.driverPhotoFileIds().isEmpty()
|
||||
|| photos.licenseFileIds().isEmpty() && request.masterLicenseFileId()==null)
|
||||
throw conflict("请补齐车辆图片、司机照片和驾驶证照片后保存");
|
||||
var old=resources(row);
|
||||
if(old.vehicle()!=null && !Objects.equals(old.vehicle().vehicleId(),request.vehicleId()))
|
||||
rejectReused(old.vehicle().evidenceFileIds(),photos.vehicleFileIds());
|
||||
if(old.driver()!=null && old.driver().evidence()!=null && !Objects.equals(old.driver().driverId(),request.driverId())) {
|
||||
rejectReused(old.driver().evidence().photoFileIds(),photos.driverPhotoFileIds());
|
||||
rejectReused(old.driver().evidence().fileIds(),photos.licenseFileIds());
|
||||
}
|
||||
var driver=drivers.requireDispatchable(request.driverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
|
||||
if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机");
|
||||
verify(row.getId(),VEHICLE,photos.vehicleFileIds());
|
||||
verify(row.getId(),DRIVER_PHOTO,photos.driverPhotoFileIds());
|
||||
verify(row.getId(),DRIVER,photos.licenseFileIds());
|
||||
var licenseIds=new ArrayList<>(photos.licenseFileIds());
|
||||
if(request.masterLicenseFileId()!=null) {
|
||||
if(!request.masterLicenseFileId().toString().equals(driver.licenseFileId()))throw conflict("司机主档驾驶证已变化,请重新选择司机");
|
||||
drivers.download(request.driverId(),request.masterLicenseFileId());
|
||||
if(licenseIds.contains(request.masterLicenseFileId()))throw conflict("驾驶证主档引用不能作为本车上传重复提交");
|
||||
licenseIds.add(request.masterLicenseFileId());
|
||||
}
|
||||
return new DriverEvidence(driver.details().licenseNumber(),driver.details().licenseExpiresOn(),List.copyOf(licenseIds),
|
||||
trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC),photos.driverPhotoFileIds(),request.masterLicenseFileId());
|
||||
}
|
||||
/** 提交和两级通过均复验照片,避免仅前端必填而绕过审核依据。 */
|
||||
public void verifyReview(DispatchAggregate row, FleetTripEntity trip) {
|
||||
var saved=resources(row);var driver=saved.driver();var vehicle=saved.vehicle();
|
||||
if(vehicle==null || driver==null || driver.evidence()==null
|
||||
|| !Objects.equals(vehicle.vehicleId(),trip.getVehicleId()) || !Objects.equals(driver.driverId(),trip.getDriverId()))
|
||||
throw conflict("请维护车辆司机并补齐审核照片");
|
||||
var e=driver.evidence();
|
||||
var licenses=e.fileIds()==null?List.<Long>of():e.fileIds().stream().filter(id->!Objects.equals(id,e.masterLicenseFileId())).toList();
|
||||
validate(row,trip,new TransportTaskModels.Assign(trip.getRecordVersion(),trip.getVehicleId(),trip.getDriverId(),"核验取货照片",
|
||||
row.getRecordVersion(),new ReviewAttachments(vehicle.evidenceFileIds(),e.photoFileIds(),licenses),e.masterLicenseFileId()));
|
||||
}
|
||||
/** 不把同排车旧司机或车辆的上传当成新对象的证据。 */
|
||||
private void rejectReused(List<Long> old,List<Long> selected) {
|
||||
if(old!=null && selected.stream().anyMatch(old::contains))throw conflict("车辆或司机已更换,请重新上传对应照片");
|
||||
}
|
||||
/** 文件网关逐项校验平台、本车和文件类别,失效或跨单引用直接失败。 */
|
||||
private void verify(long dispatch,DispatchEvidenceGateway.Kind kind,List<Long> ids) {
|
||||
for(long id:ids)files.metadata(dispatch,kind,id);
|
||||
}
|
||||
/** 返回可处理的业务冲突。 */
|
||||
private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
+1
-1
@@ -39,7 +39,7 @@ public class TransportTaskController {
|
||||
public ApiResponse<?> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));}
|
||||
/** 分配或改派真实车辆司机。 */
|
||||
@PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
|
||||
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
|
||||
/** 取消未发车任务,来源回到待安排。 */
|
||||
@PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')")
|
||||
public ApiResponse<?> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));}
|
||||
|
||||
+7
-1
@@ -18,6 +18,12 @@ public final class TransportTaskModels {
|
||||
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
|
||||
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
|
||||
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
|
||||
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence) { }
|
||||
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
|
||||
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { }
|
||||
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
|
||||
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
|
||||
@Min(0) Long dispatchRecordVersion,
|
||||
@Valid com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments reviewPhotos,
|
||||
@Positive Long masterLicenseFileId) { }
|
||||
public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { }
|
||||
}
|
||||
|
||||
+64
-5
@@ -18,12 +18,13 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */
|
||||
@Service
|
||||
public class TransportTaskProjection {
|
||||
public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.PickupDepartureReview {
|
||||
private final DispatchAggregateRepository roots;
|
||||
private final TransportTaskRepository links;
|
||||
private final UsTruckOrderRepository trucks;
|
||||
private final DispatchNumberGenerator numbers;
|
||||
private final ObjectMapper json;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickupFacts;
|
||||
/** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */
|
||||
public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) {
|
||||
this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json;
|
||||
@@ -50,11 +51,22 @@ public class TransportTaskProjection {
|
||||
var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return;
|
||||
var found=links.byTrip(trip.getId());if(found==null)return;
|
||||
var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion();
|
||||
row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt());
|
||||
row.setRecordVersion(version+1);row.setUpdatedAt(trip.getUpdatedAt());
|
||||
row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE");
|
||||
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";});
|
||||
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";case "PLANNED"->row.getStage();default->"BEFORE_LOADING";});
|
||||
// 旧发车入口只记录出发事实,取货照片尚未齐全时仍在装货中。
|
||||
if("PICKUP".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus())
|
||||
&& pickupFacts.tasks(trip.getTripNo()).stream().anyMatch(task->"ASSIGNED".equals(task.getStatus())))row.setStage("LOADING");
|
||||
row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt());
|
||||
String oldResources=row.getResourcesJson();var oldDeparture=row.getPlannedDepartureAt();var oldArrival=row.getEstimatedArrivalAt();
|
||||
row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip);
|
||||
// 资源或计划改变后作废本轮审核,保留审核驳回原因供修改时核对。
|
||||
if("PICKUP".equals(row.getTaskType()) && "PLANNED".equals(trip.getStatus())
|
||||
&& (!Objects.equals(oldResources,row.getResourcesJson()) || !Objects.equals(oldDeparture,row.getPlannedDepartureAt()) || !Objects.equals(oldArrival,row.getEstimatedArrivalAt()))) {
|
||||
row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
|
||||
row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound()));
|
||||
row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound()));
|
||||
}
|
||||
if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败");
|
||||
roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt());
|
||||
var link=links.truck(row.getDispatchNo());if(link==null)return;
|
||||
@@ -62,6 +74,30 @@ public class TransportTaskProjection {
|
||||
transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion());
|
||||
if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo());
|
||||
}
|
||||
/** 旧取货发车入口也必须锁定并核验同轮两级批准,不允许无主单发车。 */
|
||||
@Override
|
||||
public void requireApproved(long tripId) {
|
||||
var found=links.byTrip(tripId);
|
||||
if(found==null)throw new IllegalStateException("取货排车缺失,请先接入排车审核");
|
||||
var row=roots.lock(found.getDispatchNo());
|
||||
if(!"PICKUP".equals(row.getTaskType()) || !"ACTIVE".equals(row.getStatus()) || !"APPROVED".equals(row.getReviewState()) || row.getReviewBasisHash()==null)
|
||||
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"请先完成调度审核和理货员审核");
|
||||
for(String value:List.of(row.getDispatchReviewJson(),row.getLoadingReviewJson())) {
|
||||
try {
|
||||
var review=json.readValue(value,Review.class);
|
||||
if(review.status()!=ReviewStatus.APPROVED || review.reviewRound()!=row.getReviewRound() || !Objects.equals(review.basisHash(),row.getReviewBasisHash()))
|
||||
throw new IllegalStateException("取货审核依据已失效,请重新提交审核");
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
|
||||
}
|
||||
}
|
||||
/** 保留驳回事实,同时明确此前批准已因资源变更失效。 */
|
||||
private String invalidated(String value,long round) {
|
||||
try {
|
||||
var old=json.readValue(value,Review.class);
|
||||
return write(new Review(old.status()==ReviewStatus.REJECTED?ReviewStatus.REJECTED:ReviewStatus.NOT_SUBMITTED,
|
||||
old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机、照片或计划已变更,请重新提交两级审核",old.attachments()));
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
|
||||
}
|
||||
/** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */
|
||||
public void transition(String customer,String number,String target,String action,String operator,String reason,String key) {
|
||||
var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在");
|
||||
@@ -75,9 +111,32 @@ public class TransportTaskProjection {
|
||||
}
|
||||
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */
|
||||
private void resources(DispatchAggregate row,FleetTripEntity trip) {
|
||||
var old=readResources(row);
|
||||
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
|
||||
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()),
|
||||
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null))));
|
||||
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),
|
||||
old.vehicle()!=null && Objects.equals(old.vehicle().vehicleId(),trip.getVehicleId())?old.vehicle().evidenceFileIds():List.of()),
|
||||
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),
|
||||
old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null))));
|
||||
}
|
||||
/** 照片变更单独纳入主单版本和审核依据,资源分配失败时由外层事务一起回滚。 */
|
||||
public void savePhotos(String number,String operator,List<Long> vehiclePhotos,DriverEvidence driverPhotos) {
|
||||
var row=roots.lock(number);var old=readResources(row);var v=old.vehicle();var d=old.driver();
|
||||
if(v==null || d==null)throw new IllegalStateException("请先完成车辆司机分配");
|
||||
var next=new Resources(new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),vehiclePhotos),
|
||||
new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos));
|
||||
if(next.equals(old))return;
|
||||
long version=row.getRecordVersion();row.setResourcesJson(write(next));row.setRecordVersion(version+1);
|
||||
row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
|
||||
row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound()));
|
||||
row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound()));
|
||||
if(roots.saveState(row,version)!=1)throw new IllegalStateException("取货照片保存版本冲突");
|
||||
roots.history(IdWorker.getId(),row,"PICKUP_RESOURCE_PHOTOS",operator,"保存车辆、司机和驾驶证审核照片",write(row),row.getUpdatedAt());
|
||||
}
|
||||
/** 状态投影只保留同一资源的已保存照片,不能在运输流转中清空审核证据。 */
|
||||
private Resources readResources(DispatchAggregate row) {
|
||||
if(row.getResourcesJson()==null)return new Resources(null,null);
|
||||
try{return json.readValue(row.getResourcesJson(),Resources.class);}
|
||||
catch(Exception e){throw new IllegalStateException("运输资源照片读取失败",e);}
|
||||
}
|
||||
/** 保存明确的业务快照,失败时终止事务。 */
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}}
|
||||
|
||||
+42
-4
@@ -35,6 +35,7 @@ public class TransportTaskService {
|
||||
private final FleetTransportVehicleGuard vehicles;
|
||||
private final TripOperationsEvidenceGateway files;
|
||||
private final ObjectMapper json;
|
||||
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
|
||||
/** 复用已有资格、占用、原单及凭证能力。 */
|
||||
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
|
||||
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
|
||||
@@ -51,6 +52,22 @@ public class TransportTaskService {
|
||||
var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator);
|
||||
return detail(row.getDispatchNo());
|
||||
}
|
||||
/** 审核通过后按原单锁幂等生成待发车排车;重复审核保留原安排及取消结果。 */
|
||||
@Transactional
|
||||
public void ensureApprovedPickup(String customer,String number,String operator) {
|
||||
var source=pickupFacts.source(customer,number,true);
|
||||
if(source==null)throw missing("转运单不存在");
|
||||
if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !number.equals(source.getInboundCode())
|
||||
|| !"WAIT_PDA_RECEIVING".equals(source.getStatus()))return;
|
||||
var task=pickupFacts.lockTask(source.getId());
|
||||
// 已安排、执行或主动取消的取货任务均不因审核重放重新建单。
|
||||
if(task!=null)return;
|
||||
var departure=Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
createPickup(operator,new FleetPickupModels.Create(List.of(new FleetPickupModels.Source(customer,number)),
|
||||
departure,departure.plus(Duration.ofHours(24)),
|
||||
"审核自动生成到仓取货排车;时间为暂定计划,调度确认后分配车辆司机",
|
||||
"transfer-approval:"+source.getId()));
|
||||
}
|
||||
/** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) {
|
||||
@@ -79,7 +96,8 @@ public class TransportTaskService {
|
||||
var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number);
|
||||
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
|
||||
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
|
||||
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList());
|
||||
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList(),row.getRecordVersion(),
|
||||
"PICKUP".equals(row.getTaskType())?reviewPhotos.resources(row):null);
|
||||
}
|
||||
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
|
||||
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
|
||||
@@ -91,12 +109,32 @@ public class TransportTaskService {
|
||||
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务");
|
||||
String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip;
|
||||
}
|
||||
/** 分配实际资源,容量超限时整次分配回滚。 */
|
||||
/** 资源与三类照片同事务保存;双版本阻止审核期间的旧页面覆盖。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) {
|
||||
var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request);
|
||||
public TransportTaskModels.Detail assign(String number,String operator,TransportTaskModels.Assign request) {
|
||||
var trip=transport(number,true);
|
||||
com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence photos=null;
|
||||
if("PICKUP".equals(trip.getTripPurpose())) {
|
||||
var row=roots.lock(number);
|
||||
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|
||||
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
|
||||
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
|
||||
photos=reviewPhotos.validate(row,trip,request);
|
||||
}
|
||||
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
|
||||
if(photos!=null)projection.savePhotos(number,operator,request.reviewPhotos().vehicleFileIds(),photos);
|
||||
if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number);
|
||||
}
|
||||
/** 取货提交与两级通过时复验真实占用、有效证照和车辆容量。 */
|
||||
public void verifyPickupReview(String number) {
|
||||
var trip=transport(number,true);
|
||||
if(!"PICKUP".equals(trip.getTripPurpose()) || !"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).isEmpty())throw conflict("仅有真实取货任务的未发车排车可以审核");
|
||||
if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null || !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间");
|
||||
var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm();
|
||||
requireResources(trip);
|
||||
reviewPhotos.verifyReview(roots.find(number),trip);
|
||||
if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息");
|
||||
}
|
||||
/** 取消运输并保留原单、排车及资源审计。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.port;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 整车装车资料公开端口;排车只组合资料事实,不读取车队持久化实现。 */
|
||||
public interface DispatchLoadingPhotoPort {
|
||||
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
|
||||
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
||||
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
|
||||
record State(String recordVersion,List<Photo> photos) {}
|
||||
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
|
||||
record Download(Photo photo,byte[] content) {}
|
||||
/** 只读取原整车附加资料版本与五框,不初始化记录。 */
|
||||
State read(long dispatchId);
|
||||
/** 上传保存原请求意图;不提前计入已保存照片。 */
|
||||
Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file);
|
||||
/** 仅原操作者可恢复原上传结果。 */
|
||||
Photo recover(long dispatchId,String operator,String key);
|
||||
/** 先校验原槽位、完整载荷与操作者;无原成功命令时返回null。 */
|
||||
Documents replayBind(long dispatchId,String operator,Slot slot,BindPhoto request);
|
||||
/** 调用者已持排车父锁;保存照片、资料版本及历史但不单独提交事务。 */
|
||||
State bind(long dispatchId,String operator,Slot slot,BindPhoto request);
|
||||
/** 与绑定及自动发车共同提交完整原结果。 */
|
||||
void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result);
|
||||
/** 文件只可从本车当前已绑定槽位下载。 */
|
||||
Download content(long dispatchId,Slot slot,long fileId);
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||
|
||||
/** 在原照片表和命令账本实现装车资料端口,不反向调用排车履约服务。 */
|
||||
@Service
|
||||
public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
|
||||
private final OwnedBusinessRepository repository;
|
||||
private final OwnedBusinessCommandService commands;
|
||||
private final OwnedBusinessProjection projection;
|
||||
private final OwnedBusinessSupport codec;
|
||||
private final OwnedBusinessFileGateway files;
|
||||
/** 复用现有照片、版本、历史及文件归属校验。 */
|
||||
public DispatchLoadingPhotoAdapter(OwnedBusinessRepository repository,OwnedBusinessCommandService commands,
|
||||
OwnedBusinessProjection projection,OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
|
||||
this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
|
||||
}
|
||||
public record Binding(Slot slot,BindPhoto request) {}
|
||||
/** GET只读;首次保存前资料版本为0。 */
|
||||
@Override public State read(long id) {
|
||||
var row=repository.record(id);
|
||||
return new State(row==null?"0":row.getRecordVersion().toString(),projection.photos(row).stream()
|
||||
.filter(p->"LOADING".equals(p.category())).map(this::publicPhoto).toList());
|
||||
}
|
||||
/** 先落稳定上传意图,再请求File;未知结果不允许换键重传。 */
|
||||
@Override public Photo upload(long id,String operator,Slot slot,String key,MultipartFile multipart) {
|
||||
codec.key(key);var originalSlot=slot(slot);
|
||||
if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");}
|
||||
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
|
||||
HttpOwnedBusinessFileGateway.validate(payload);
|
||||
var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
|
||||
var reservation=commands.reserveUpload(id,operator(operator),key,identity);
|
||||
if(!reservation.created())return uploaded(reservation.command());
|
||||
try {
|
||||
var result=photo(originalSlot,files.upload(id,originalSlot,operator,payload));
|
||||
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return publicPhoto(result);
|
||||
} catch(RuntimeException failure) {
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");}
|
||||
catch(RuntimeException recording){failure.addSuppressed(recording);}
|
||||
throw conflict("照片上传结果未知,请保留原请求键核对");
|
||||
}
|
||||
}
|
||||
/** 上传结果仅限本车原操作者及装车槽位。 */
|
||||
@Override public Photo recover(long id,String operator,String key) {
|
||||
codec.key(key);var command=commands.existing(id,key,operator(operator));
|
||||
if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求");return uploaded(command);
|
||||
}
|
||||
/** 完整原结果先于当前资料版本比较,防止重复推进。 */
|
||||
@Override public Documents replayBind(long id,String operator,Slot slot,BindPhoto request) {
|
||||
validate(slot,request);var old=commands.existing(id,request.idempotencyKey(),operator(operator));
|
||||
if(old==null)return null;
|
||||
commands.same(old,"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request));
|
||||
if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认");return codec.read(old.getResultJson(),Documents.class);
|
||||
}
|
||||
/** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */
|
||||
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
|
||||
public State bind(long id,String operator,Slot slot,BindPhoto request) {
|
||||
validate(slot,request);long version=codec.version(request.recordVersion());var originalSlot=slot(slot);
|
||||
var verified=photo(originalSlot,files.metadata(id,originalSlot,codec.version(request.fileId())));
|
||||
var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(id);
|
||||
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
|
||||
var row=repository.lock(seed);
|
||||
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
|
||||
var photos=new EnumMap<PhotoSlot,OwnedBusinessModels.Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
|
||||
photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
|
||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
|
||||
history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
|
||||
history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),projection.photos(row),operator)));repository.history(history);
|
||||
return read(id);
|
||||
}
|
||||
/** 保存调用者提供的最终阶段,命令与资料、派送同事务提交。 */
|
||||
@Override @Transactional(propagation=Propagation.MANDATORY)
|
||||
public void recordBind(long id,String operator,Slot slot,BindPhoto request,Documents result) {
|
||||
commands.success(id,operator(operator),request.idempotencyKey(),"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request),result);
|
||||
}
|
||||
/** 下载仅允许当前已保存引用,不接受未绑定上传或其他车文件。 */
|
||||
@Override public Download content(long id,Slot slot,long fileId) {
|
||||
var originalSlot=slot(slot);var photo=projection.photos(repository.record(id)).stream()
|
||||
.filter(p->p.slot()==originalSlot && String.valueOf(fileId).equals(p.fileId())).findFirst()
|
||||
.orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片"));
|
||||
return new Download(publicPhoto(photo),files.content(id,originalSlot,fileId));
|
||||
}
|
||||
/** 原五框技术名保持文件归属,拒绝油耗框作为装车资料。 */
|
||||
private PhotoSlot slot(Slot slot){if(slot==null)throw bad("请选择有效的装车照片框");return PhotoSlot.valueOf(slot.name());}
|
||||
/** 版本0合法,文件编号必须为正且请求键不得缺失。 */
|
||||
private void validate(Slot slot,BindPhoto request) {
|
||||
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
|
||||
if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
|
||||
}
|
||||
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
|
||||
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
|
||||
/** 回执必须是已知成功上传,其他类别或未明确结果不得充数。 */
|
||||
private Photo uploaded(OwnedBusinessRows.Command command) {
|
||||
if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null)throw conflict("原上传结果尚未确认,请保留原请求");
|
||||
var value=codec.read(command.getResultJson(),OwnedBusinessModels.Photo.class);
|
||||
if(!"LOADING".equals(value.category()))throw bad("原上传不是装车照片");return publicPhoto(value);
|
||||
}
|
||||
/** 外部事实仅投影照片字段,不暴露持久化记录。 */
|
||||
private Photo publicPhoto(OwnedBusinessModels.Photo photo) {
|
||||
return new Photo(Slot.valueOf(photo.slot().name()),photo.category(),photo.fileId(),photo.fileName(),photo.contentType(),photo.sizeBytes(),photo.uploadedAt(),photo.savedAt());
|
||||
}
|
||||
/** 上传回执不伪造保存时间。 */
|
||||
private OwnedBusinessModels.Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) {
|
||||
return new OwnedBusinessModels.Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> {
|
||||
/** 解除计划中的取货关联,保留历史后允许再次安排。 */
|
||||
@Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
|
||||
int unassign(@Param("tripNo") String tripNo);
|
||||
/** 原单同时取消时保留车次关联供详情和审计追溯,不再回到待安排。 */
|
||||
@Update("UPDATE ll_fleet_pickup_task SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
|
||||
int cancelTasks(@Param("tripNo") String tripNo);
|
||||
/** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */
|
||||
@Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})")
|
||||
int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip,
|
||||
|
||||
+8
-2
@@ -24,7 +24,10 @@ public final class FleetPickupModels {
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Command(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@NotBlank @Size(max=1000) String reason) { }
|
||||
@NotBlank @Size(max=1000) String reason,List<@NotNull @Positive Long> evidenceFileIds) {
|
||||
/** 无照片的非送仓命令保持原调用形式。 */
|
||||
public Command(Long recordVersion,String idempotencyKey,String reason){this(recordVersion,idempotencyKey,reason,null);}
|
||||
}
|
||||
public record Pickup(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@Min(1) @Max(5000) int actualPackages,
|
||||
@@ -35,6 +38,9 @@ public final class FleetPickupModels {
|
||||
String status, String tripNo, String vehiclePlate, String driverName, String driverPhone,
|
||||
Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt,
|
||||
String remark, List<Evidence> evidence) { }
|
||||
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes,String phase) {
|
||||
/** 旧取货凭证明确归入取货阶段,不冒充到仓照片。 */
|
||||
public Evidence(String fileId,String fileName,String contentType,long sizeBytes){this(fileId,fileName,contentType,sizeBytes,"PICKUP");}
|
||||
}
|
||||
public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { }
|
||||
}
|
||||
|
||||
+2
@@ -34,6 +34,8 @@ public class FleetPickupRepository {
|
||||
public FleetPickupTask lockTask(long id) { return mapper.lockTask(id); }
|
||||
/** 释放未开始的成员关联。 */
|
||||
public int unassign(String number) { return mapper.unassign(number); }
|
||||
/** 联动取消保留原车次与转运单的可追溯关联。 */
|
||||
public int cancelTasks(String number) { return mapper.cancelTasks(number); }
|
||||
/** 保存不可变业务操作记录。 */
|
||||
public void history(FleetPickupTask task,String action,String operator,String detail) { mapper.history(task.getCustomerId(),task.getTransferNo(),task.getTripNo(),action,operator,detail); }
|
||||
/** 核对旧拼车承运关系,防止同票走两条运输链。 */
|
||||
|
||||
+26
-4
@@ -28,11 +28,13 @@ public class FleetPickupService {
|
||||
private final VehicleRepository vehicles;
|
||||
private final TripOperationsEvidenceGateway files;
|
||||
private final ObjectMapper json;
|
||||
private final PickupDepartureReview departureReview;
|
||||
/** 共用现有车次、司机资格、车辆占用及鉴权文件能力。 */
|
||||
public FleetPickupService(FleetPickupRepository repository,FleetTripService trips,FleetWarehouseDirectory warehouses,
|
||||
DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) {
|
||||
DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json,PickupDepartureReview departureReview) {
|
||||
this.repository=repository;this.trips=trips;this.warehouses=warehouses;this.drivers=drivers;
|
||||
this.vehicles=vehicles;this.files=files;this.json=json;
|
||||
this.departureReview=departureReview;
|
||||
}
|
||||
/** 审核通过的原单自动出现在候选中,客户自送和库存转运不参与。 */
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
@@ -120,6 +122,7 @@ public class FleetPickupService {
|
||||
if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number);
|
||||
version(trip,request.recordVersion());
|
||||
if(!"PLANNED".equals(trip.getStatus()) || repository.tasks(number).isEmpty())throw conflict("仅有取货任务的计划车次可以发车");
|
||||
departureReview.requireApproved(trip.getId());
|
||||
requireResources(trip);
|
||||
for(var task:repository.tasks(number))eligible(source(task.getCustomerId(),task.getTransferNo(),true));
|
||||
trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
|
||||
@@ -142,28 +145,47 @@ public class FleetPickupService {
|
||||
var trip=pickupTrip(number,true);String hash=commandHash("PICKED:"+customer+":"+transferNo,request);
|
||||
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
|
||||
version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
|
||||
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("请先发车,且该单尚未确认取货");
|
||||
if(!Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("该单尚未处于可确认取货阶段");
|
||||
if("PLANNED".equals(trip.getStatus()))departureReview.requireApproved(trip.getId());
|
||||
if(request.actualPackages()<=0 || request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty() || request.evidenceFileIds().size()>10)throw bad("请填写实提箱数并上传取货照片");
|
||||
eligible(source(customer,transferNo,true));
|
||||
var evidence=request.evidenceFileIds().stream().distinct().map(id->{
|
||||
if(!repository.ownsUpload(task.getTransferOrderId(),number,id))throw bad("取货凭证不属于当前订单或车次");
|
||||
var file=files.metadata(trip.getId(),id);
|
||||
if(file.contentType()==null || !file.contentType().startsWith("image/"))throw bad("请上传有效取货照片");
|
||||
return new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes());
|
||||
}).toList();
|
||||
task.setStatus("PICKED");task.setActualPackages(request.actualPackages());task.setPickedAt(now());
|
||||
task.setEvidenceJson(write(evidence));task.setRemark(blank(request.remark()));task.setUpdatedAt(now());repository.update(task);
|
||||
repository.history(task,"PICKED",operator,"实提"+request.actualPackages()+"箱;"+Objects.toString(task.getRemark(),""));
|
||||
// 全车照片和实提数量核齐后自动进入派送中,不能以发车按钮代替取货照片。
|
||||
if("PLANNED".equals(trip.getStatus()) && repository.tasks(number).stream().allMatch(row->"PICKED".equals(row.getStatus()))) {
|
||||
requireResources(trip);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
|
||||
}
|
||||
trips.recordExecution(trip,request.recordVersion(),"PICKUP_PICKED","确认原单取货",operator);
|
||||
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
|
||||
}
|
||||
/** 到仓确认只开放 PDA 清点,不覆盖预报箱数和 SKU 件数。 */
|
||||
/** 到仓照片独立归档,全部送仓后自动完成本次取货派送并开放PDA清点。 */
|
||||
@Transactional
|
||||
public List<FleetPickupModels.Task> arrive(String number,String customer,String transferNo,String operator,FleetPickupModels.Command request) {
|
||||
var trip=pickupTrip(number,true);String hash=commandHash("ARRIVED:"+customer+":"+transferNo,request);
|
||||
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
|
||||
version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
|
||||
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"PICKED".equals(task.getStatus()))throw conflict("请先确认该单已取货");
|
||||
source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setUpdatedAt(now());repository.update(task);
|
||||
if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty() || request.evidenceFileIds().size()>10)throw bad("请上传到仓照片");
|
||||
var old=evidence(task);var arrival=new ArrayList<FleetPickupModels.Evidence>(old);
|
||||
for(long id:request.evidenceFileIds().stream().distinct().toList()) {
|
||||
if(!repository.ownsUpload(task.getTransferOrderId(),number,id) || old.stream().anyMatch(file->file.fileId().equals(String.valueOf(id))))throw bad("请上传本票新的到仓照片,不能复用取货照片");
|
||||
var file=files.metadata(trip.getId(),id);
|
||||
if(file.contentType()==null || !file.contentType().startsWith("image/"))throw bad("请上传有效到仓照片");
|
||||
arrival.add(new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes(),"ARRIVAL"));
|
||||
}
|
||||
source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setEvidenceJson(write(arrival));task.setUpdatedAt(now());repository.update(task);
|
||||
repository.history(task,"ARRIVED",operator,request.reason());
|
||||
if(repository.tasks(number).stream().allMatch(row->"ARRIVED".equals(row.getStatus()))) {
|
||||
trip.setStatus("COMPLETED");trip.setCompletedAt(now());
|
||||
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of())));
|
||||
}
|
||||
trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator);
|
||||
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.lastleg.fleet.pickup;
|
||||
|
||||
/** 取货发车必须通过排车主单的本轮两级审核。 */
|
||||
public interface PickupDepartureReview {
|
||||
/** 调用方已持车次锁,校验并锁定对应排车审核事实。 */
|
||||
void requireApproved(long tripId);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.fleet.pickup;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 原单所属域负责取消未执行取货的转运业务,失败时整车取消回滚。 */
|
||||
public interface PickupOrderCancellation {
|
||||
/** 调用方持有车次锁,按稳定来源顺序取消全部成员。 */
|
||||
void cancel(List<FleetPickupModels.Source> sources,String tripNo,String operator,String reason);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public class FleetTripService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
|
||||
private final FleetTripRepository repository;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation;
|
||||
private final FleetDispatchGateway dispatches;
|
||||
private final VehicleRepository vehicles;
|
||||
private final DriverService drivers;
|
||||
@@ -467,18 +468,20 @@ public class FleetTripService {
|
||||
|
||||
|
||||
|
||||
/** 取消未执行车次,释放资源并将取货原单退回待安排。 */
|
||||
/** 取消未执行车次,取货同时取消全部转运原单并释放资源。 */
|
||||
@Transactional
|
||||
public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) {
|
||||
FleetTripEntity trip = planned(tripNo, request.recordVersion());
|
||||
requireReason(request.reason());
|
||||
if ("PICKUP".equals(trip.getTripPurpose())) {
|
||||
// 取消车次与释放原单关联同事务,失败时全部回滚。
|
||||
for (var task : pickups.tasks(tripNo)) {
|
||||
var tasks=pickups.tasks(tripNo);
|
||||
for (var task : tasks) {
|
||||
if (!"ASSIGNED".equals(task.getStatus())) throw conflict("已开始取货,不能取消车次");
|
||||
pickups.history(task,"UNASSIGN",operator,request.reason());
|
||||
}
|
||||
pickups.unassign(tripNo);
|
||||
pickupCancellation.cancel(tasks.stream().map(task->new com.baiyue.lastleg.fleet.pickup.FleetPickupModels.Source(task.getCustomerId(),task.getTransferNo())).toList(),tripNo,operator,request.reason());
|
||||
for(var task:tasks)pickups.history(task,"CANCEL",operator,"取消排车及转运单:"+request.reason());
|
||||
if(pickups.cancelTasks(tripNo)!=tasks.size())throw conflict("取货关联已变化,取消未完成");
|
||||
}
|
||||
if (!repository.listBindings(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
|
||||
if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次");
|
||||
@@ -554,6 +557,7 @@ public class FleetTripService {
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
|
||||
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
|
||||
if("PICKUP".equals(trip.getTripPurpose()) && pickups.tasks(number).stream().anyMatch(task->!"ASSIGNED".equals(task.getStatus())))throw conflict("已开始取货,不能修改资源、计划或取消排车");
|
||||
return trip;
|
||||
}
|
||||
/** 复核创建请求摘要,同键不同内容不允许复用。 */
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.transfer.mapper;
|
||||
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
/** 仅取消本客户外来取货的待执行原单,不释放或回滚任何已入库库存。 */
|
||||
@Mapper
|
||||
public interface TransferPickupCancellationMapper {
|
||||
/** 与PDA共用转运原单锁,串行核验收货事实。 */
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE")
|
||||
WarehouseTransferOrder lock(@Param("customer") String customer,@Param("number") String number);
|
||||
/** 任何收货、箱清点或托盘测量开始后均禁止普通取消。 */
|
||||
@Select("SELECT (SELECT COUNT(*) FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customer} AND transfer_order_id=#{id}) + (SELECT COUNT(*) FROM ll_warehouse_transfer_carton WHERE customer_id=#{customer} AND transfer_order_id=#{id} AND counted_at IS NOT NULL) + (SELECT COUNT(*) FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customer} AND transfer_order_id=#{id})")
|
||||
int started(@Param("customer") String customer,@Param("id") long id);
|
||||
/** 同一取消事务只接受待PDA收货的转运单。 */
|
||||
@Update("UPDATE ll_warehouse_transfer_order SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND status='WAIT_PDA_RECEIVING'")
|
||||
int cancelTransfer(@Param("customer") String customer,@Param("id") long id);
|
||||
/** 写入主单取消事实并递增版本,防止旧页面继续写入。 */
|
||||
@Update("UPDATE ll_order SET current_status='CANCELLED',sub_order_status='WAREHOUSE_TRANSFER:CANCELLED',version=version+1,updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND current_status='INBOUND_MEASURING'")
|
||||
int cancelOrder(@Param("customer") String customer,@Param("id") long id);
|
||||
/** 只取消尚未接货的外来入仓单。 */
|
||||
@Update("UPDATE ll_inbound_order SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND status='PENDING_INBOUND'")
|
||||
int cancelInbound(@Param("customer") String customer,@Param("id") long id);
|
||||
/** 只取消原转运生成且仍待PDA的唯一计划。 */
|
||||
@Update("UPDATE ll_inbound_plan SET status='CANCELLED',version=version+1,updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND source_order_id=#{order} AND creation_mode='WAREHOUSE_TRANSFER' AND status='PDA_PENDING'")
|
||||
int cancelPlan(@Param("customer") String customer,@Param("order") long order);
|
||||
/** 不可变历史保留取消前状态、原车次与操作者。 */
|
||||
@Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,event_id,remark,created_at) SELECT customer_id,id,current_status,'CANCELLED',#{trip},CONCAT('取消取货排车及转运单;操作人:',#{operator},';原因:',#{reason}),CURRENT_TIMESTAMP FROM ll_order WHERE customer_id=#{customer} AND id=#{id}")
|
||||
int history(@Param("customer") String customer,@Param("id") long id,@Param("trip") String trip,@Param("operator") String operator,@Param("reason") String reason);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.transfer.repository;
|
||||
|
||||
import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 未执行取货取消的原单、入仓与历史持久化边界。 */
|
||||
@Repository
|
||||
public class TransferPickupCancellationRepository {
|
||||
private final TransferPickupCancellationMapper mapper;
|
||||
/** 注入本业务取消映射器。 */
|
||||
public TransferPickupCancellationRepository(TransferPickupCancellationMapper mapper){this.mapper=mapper;}
|
||||
/** 锁定真实客户所属转运来源。 */
|
||||
public com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder lock(String customer,String number){return mapper.lock(customer,number);}
|
||||
/** 已存在清点、托盘或收货事实时禁止普通取消。 */
|
||||
public boolean started(String customer,long id){return mapper.started(customer,id)>0;}
|
||||
/** 取消转运并检查当前状态。 */
|
||||
public int cancelTransfer(String customer,long id){return mapper.cancelTransfer(customer,id);}
|
||||
/** 取消尾程主单并记录当前状态来源。 */
|
||||
public int cancelOrder(String customer,long id,String trip,String operator,String reason){mapper.history(customer,id,trip,operator,reason);return mapper.cancelOrder(customer,id);}
|
||||
/** 取消原入仓单,保留不可删除的业务关联。 */
|
||||
public int cancelInbound(String customer,long id){return mapper.cancelInbound(customer,id);}
|
||||
/** 取消待收货计划,已执行的计划导致整车取消失败。 */
|
||||
public int cancelPlan(String customer,long order){return mapper.cancelPlan(customer,order);}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.transfer.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.pickup.*;
|
||||
import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
|
||||
/** 取货排车取消时原子取消其全部转运、尾程、入仓单及待执行入仓计划。 */
|
||||
@Service
|
||||
public class TransferPickupCancellationService implements PickupOrderCancellation {
|
||||
private final TransferPickupCancellationRepository repository;
|
||||
/** 取消只访问本域持久化边界,不反向调用排车服务。 */
|
||||
public TransferPickupCancellationService(TransferPickupCancellationRepository repository){this.repository=repository;}
|
||||
/** 任一来源已经执行或归属不符时抛错,使外层整车取消全部回滚。 */
|
||||
@Override @Transactional(propagation=Propagation.MANDATORY)
|
||||
public void cancel(List<FleetPickupModels.Source> sources,String tripNo,String operator,String reason) {
|
||||
if(sources==null || sources.isEmpty())throw conflict();
|
||||
for(var source:sources) {
|
||||
var row=repository.lock(source.customerId(),source.transferNo());
|
||||
if(row==null || !"WAREHOUSE_PICKUP".equals(row.getDeliveryMethod()) || !row.getTransferNo().equals(row.getInboundCode()) || !"WAIT_PDA_RECEIVING".equals(row.getStatus()) || repository.started(source.customerId(),row.getId()))throw conflict();
|
||||
if(repository.cancelTransfer(source.customerId(),row.getId())!=1
|
||||
|| repository.cancelOrder(source.customerId(),row.getLastlegOrderId(),tripNo,operator,reason)!=1
|
||||
|| repository.cancelInbound(source.customerId(),row.getInboundOrderId())!=1
|
||||
|| repository.cancelPlan(source.customerId(),row.getLastlegOrderId())!=1)throw conflict();
|
||||
}
|
||||
}
|
||||
/** 保留用户上下文,不把部分取消作为成功返回。 */
|
||||
private static ResponseStatusException conflict(){return new ResponseStatusException(HttpStatus.CONFLICT,"转运单已开始收货或关联状态变化,不能取消排车及转运单,请刷新核对");}
|
||||
}
|
||||
+6
@@ -94,6 +94,7 @@ import java.util.stream.Collectors;
|
||||
public class WarehouseTransferService {
|
||||
@Resource private TransferQuantityService transferQuantityService;
|
||||
@Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService;
|
||||
@Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTaskService;
|
||||
/** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */
|
||||
public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:";
|
||||
@Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
|
||||
@@ -814,6 +815,7 @@ public class WarehouseTransferService {
|
||||
return new WarehouseTransferResponses.Label(transferNo, cartons, html);
|
||||
}
|
||||
|
||||
/** 审核与入仓计划、上门取货排车原子提交,重放复用原业务关联。 */
|
||||
@Transactional
|
||||
public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId,
|
||||
WarehouseTransferRequests.Review request) {
|
||||
@@ -836,6 +838,7 @@ public class WarehouseTransferService {
|
||||
.contains(transfer.getStatus())) {
|
||||
if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) {
|
||||
activateTransferInbound(customerId, transfer, LocalDateTime.now());
|
||||
transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId);
|
||||
}
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
@@ -865,6 +868,9 @@ public class WarehouseTransferService {
|
||||
"Warehouse transfer reviewed: " + next
|
||||
+ (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now);
|
||||
}
|
||||
if (APPROVED.equals(next) && incomingTransit) {
|
||||
transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId);
|
||||
}
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 排车装车资料(Admin/PDA共用)
|
||||
version: 1.0.0
|
||||
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents:
|
||||
get:
|
||||
operationId: getDispatchLoadingDocuments
|
||||
summary: 读取本车装车资料或原上传结果
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:list
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Documents'
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents:
|
||||
get:
|
||||
operationId: getDispatchLoadingDocumentsPda
|
||||
summary: 读取本车装车资料或原上传结果
|
||||
x-permission-code: lastleg:pda:operate
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Documents'
|
||||
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads:
|
||||
post:
|
||||
operationId: uploadDispatchLoadingPhoto
|
||||
summary: 上传整车装车照片,尚未绑定
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- file
|
||||
- idempotencyKey
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 非空JPG/PNG,最大10MiB
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads:
|
||||
post:
|
||||
operationId: uploadDispatchLoadingPhotoPda
|
||||
summary: 上传整车装车照片,尚未绑定
|
||||
x-permission-code: lastleg:pda:operate
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- file
|
||||
- idempotencyKey
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 非空JPG/PNG,最大10MiB
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}:
|
||||
put:
|
||||
operationId: bindDispatchLoadingPhoto
|
||||
summary: 保存装车照片;五框齐全时自动进入派送中
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Documents'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BindPhoto'
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}:
|
||||
put:
|
||||
operationId: bindDispatchLoadingPhotoPda
|
||||
summary: 保存装车照片;五框齐全时自动进入派送中
|
||||
x-permission-code: lastleg:pda:operate
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Documents'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BindPhoto'
|
||||
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content:
|
||||
get:
|
||||
operationId: readDispatchLoadingPhoto
|
||||
summary: 读取本车装车资料或原上传结果
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:list
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
- $ref: '#/components/parameters/fileId'
|
||||
responses:
|
||||
'200':
|
||||
description: no-store图片内容
|
||||
content:
|
||||
image/jpeg:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content:
|
||||
get:
|
||||
operationId: readDispatchLoadingPhotoPda
|
||||
summary: 读取本车装车资料或原上传结果
|
||||
x-permission-code: lastleg:pda:operate
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
- $ref: '#/components/parameters/fileId'
|
||||
responses:
|
||||
'200':
|
||||
description: no-store图片内容
|
||||
content:
|
||||
image/jpeg:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}:
|
||||
get:
|
||||
operationId: recoverDispatchLoadingPhoto
|
||||
summary: 读取本车装车资料或原上传结果
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/idempotencyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}:
|
||||
get:
|
||||
operationId: recoverDispatchLoadingPhotoPda
|
||||
summary: 读取本车装车资料或原上传结果
|
||||
x-permission-code: lastleg:pda:operate
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/idempotencyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
dispatchNo:
|
||||
name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
slot:
|
||||
name: slot
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: &id001
|
||||
- LOADING_SCENE
|
||||
- LOADING_LIST
|
||||
- PRE_DEPARTURE_CHECK
|
||||
- DRIVER_LICENSE
|
||||
- RECEIPT
|
||||
fileId:
|
||||
name: fileId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
idempotencyKey:
|
||||
name: idempotencyKey
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
schemas:
|
||||
Photo:
|
||||
type: object
|
||||
required:
|
||||
- slot
|
||||
- fileId
|
||||
- fileName
|
||||
- contentType
|
||||
- sizeBytes
|
||||
- uploadedAt
|
||||
properties:
|
||||
slot:
|
||||
type: string
|
||||
enum: *id001
|
||||
category:
|
||||
type: string
|
||||
enum:
|
||||
- LOADING
|
||||
fileId:
|
||||
type: string
|
||||
fileName:
|
||||
type: string
|
||||
contentType:
|
||||
type: string
|
||||
sizeBytes:
|
||||
type: string
|
||||
uploadedAt:
|
||||
type: string
|
||||
savedAt:
|
||||
type: string
|
||||
nullable: true
|
||||
Documents:
|
||||
type: object
|
||||
required:
|
||||
- dispatchNo
|
||||
- recordVersion
|
||||
- editable
|
||||
- photos
|
||||
- dispatchRecordVersion
|
||||
- stage
|
||||
- complete
|
||||
properties:
|
||||
dispatchNo:
|
||||
type: string
|
||||
recordVersion:
|
||||
type: string
|
||||
editable:
|
||||
type: boolean
|
||||
photos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
dispatchRecordVersion:
|
||||
type: string
|
||||
stage:
|
||||
type: string
|
||||
enum:
|
||||
- LOADING
|
||||
- AFTER_LOADING
|
||||
- DELIVERING
|
||||
- DELIVERED
|
||||
complete:
|
||||
type: boolean
|
||||
description: 五个固定框各有一张已保存照片
|
||||
BindPhoto:
|
||||
type: object
|
||||
required:
|
||||
- recordVersion
|
||||
- idempotencyKey
|
||||
- fileId
|
||||
properties:
|
||||
recordVersion:
|
||||
type: string
|
||||
pattern: ^(0|[1-9][0-9]*)$
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
fileId:
|
||||
type: string
|
||||
@@ -8,6 +8,10 @@ info:
|
||||
list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。
|
||||
创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。
|
||||
到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。
|
||||
仓储转运上门取货入仓单审核通过时自动生成待发车排车并绑定原转运单;重复审核不重复建单,主动取消排车时对应转运、尾程、入仓单和待收货计划一并取消。
|
||||
PICKUP 维护车辆后复用 dispatches/{dispatchNo}/submit-review、dispatch-review、loading-review 两级审核入口及版本化命令。
|
||||
调度通过后待理货审核,任一级驳回返回车辆信息修改重提;两级通过后进入装货中;逐票上传取货照片并确认后自动进入派送中,逐票上传到仓照片并确认后自动完成取货派送,再走原仓库收货流程,不走仓内拣货扣库存。
|
||||
车辆、司机或计划时刻变化作废本轮审核;运输版本与排车审核版本各自递增,命令使用对应详情的当前版本。
|
||||
servers:
|
||||
- url: /api/platform/v1/last-leg/dispatches/transport-tasks
|
||||
security:
|
||||
@@ -38,7 +42,7 @@ paths:
|
||||
- { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 }
|
||||
get:
|
||||
summary: 排车运输详情(list)
|
||||
description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)。
|
||||
description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(取货车辆司机照片快照;其他类型为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。
|
||||
responses: *response
|
||||
/{number}/{action}:
|
||||
parameters:
|
||||
@@ -48,6 +52,10 @@ paths:
|
||||
summary: 分配、取消、资源可用性查询与实际任务释放
|
||||
description: >-
|
||||
assign(edit):recordVersion、vehicleId、driverId、reason;cancel(cancel):recordVersion、reason。
|
||||
PICKUP分配还必须提交dispatchRecordVersion和reviewPhotos(vehicleFileIds、driverPhotoFileIds、licenseFileIds),每类最多20个唯一正整数ID。
|
||||
车辆图片和司机照片各至少一张,驾驶证须有本车licenseFileIds或masterLicenseFileId;后者仅可引用所选司机当前有效主档驾驶证,不放入请求的licenseFileIds。
|
||||
所有补传经dispatches/{number}/evidence/{VEHICLE|DRIVER_PHOTO|DRIVER}上传并校验本车类别归属;主档预览复用司机档案受权下载。
|
||||
资源、照片与审核失效同事务,双版本过期或任一照片不合法则不分配资源。重新换车/司机不得沿用对应旧照片,照片修改必须重新两级审核,运输状态同步保留同一资源的照片。
|
||||
resource-occupancy(list):kind=VEHICLE/DRIVER、resourceIds数组。
|
||||
release(edit):recordVersion、resourceKind、actualEndAt、endLocation、vehicleStatus(车辆必填)、idempotencyKey、reason。
|
||||
responses: *response
|
||||
|
||||
@@ -1,379 +1,242 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "排车车辆资料",
|
||||
"version": "1.0.0",
|
||||
"description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。"
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": {
|
||||
"get": {
|
||||
"operationId": "getDispatchVehicleDocuments",
|
||||
"summary": "读取本车油耗图片;复用车队附加记录",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Documents"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": {
|
||||
"post": {
|
||||
"operationId": "uploadDispatchVehiclePhoto",
|
||||
"summary": "准备本车油耗图片;尚未绑定保存",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/slot"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Photo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"file",
|
||||
"idempotencyKey"
|
||||
],
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "非空JPG/PNG,最大10MiB"
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": {
|
||||
"put": {
|
||||
"operationId": "bindDispatchVehiclePhoto",
|
||||
"summary": "按附加版本绑定原上传文件,不改变排车或签收状态",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/slot"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Documents"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BindPhoto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": {
|
||||
"get": {
|
||||
"operationId": "readDispatchVehiclePhoto",
|
||||
"summary": "只读取本车当前绑定图片",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/slot"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/fileId"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "no-store图片内容",
|
||||
"content": {
|
||||
"image/jpeg": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"image/png": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": {
|
||||
"get": {
|
||||
"operationId": "recoverDispatchVehiclePhoto",
|
||||
"summary": "只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/idempotencyKey"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Photo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"dispatchNo": {
|
||||
"name": "dispatchNo",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"slot": {
|
||||
"name": "slot",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FUEL_START",
|
||||
"FUEL_END"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fileId": {
|
||||
"name": "fileId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"name": "idempotencyKey",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Photo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"slot",
|
||||
"fileId",
|
||||
"fileName",
|
||||
"contentType",
|
||||
"sizeBytes",
|
||||
"uploadedAt"
|
||||
],
|
||||
"properties": {
|
||||
"slot": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FUEL_START",
|
||||
"FUEL_END"
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FUEL"
|
||||
]
|
||||
},
|
||||
"fileId": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string"
|
||||
},
|
||||
"contentType": {
|
||||
"type": "string"
|
||||
},
|
||||
"sizeBytes": {
|
||||
"type": "string"
|
||||
},
|
||||
"uploadedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"savedAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Documents": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"dispatchNo",
|
||||
"recordVersion",
|
||||
"editable",
|
||||
"photos"
|
||||
],
|
||||
"properties": {
|
||||
"dispatchNo": {
|
||||
"type": "string"
|
||||
},
|
||||
"recordVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"editable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"photos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Photo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BindPhoto": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"recordVersion",
|
||||
"idempotencyKey",
|
||||
"fileId"
|
||||
],
|
||||
"properties": {
|
||||
"recordVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9][0-9]*)$"
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"fileId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 排车车辆资料
|
||||
version: 1.0.0
|
||||
description: 仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders
|
||||
documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents:
|
||||
get:
|
||||
operationId: getDispatchVehicleDocuments
|
||||
summary: 读取本车油耗图片;复用车队附加记录
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:list
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Documents'
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads:
|
||||
post:
|
||||
operationId: uploadDispatchVehiclePhoto
|
||||
summary: 准备本车油耗图片;尚未绑定保存
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- file
|
||||
- idempotencyKey
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 非空JPG/PNG,最大10MiB
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}:
|
||||
put:
|
||||
operationId: bindDispatchVehiclePhoto
|
||||
summary: 按附加版本绑定原上传文件,不改变排车或签收状态
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Documents'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BindPhoto'
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content:
|
||||
get:
|
||||
operationId: readDispatchVehiclePhoto
|
||||
summary: 只读取本车当前绑定图片
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:list
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
- $ref: '#/components/parameters/fileId'
|
||||
responses:
|
||||
'200':
|
||||
description: no-store图片内容
|
||||
content:
|
||||
image/jpeg:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}:
|
||||
get:
|
||||
operationId: recoverDispatchVehiclePhoto
|
||||
summary: 只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/idempotencyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
dispatchNo:
|
||||
name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
slot:
|
||||
name: slot
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- FUEL_START
|
||||
- FUEL_END
|
||||
fileId:
|
||||
name: fileId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
idempotencyKey:
|
||||
name: idempotencyKey
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
schemas:
|
||||
Photo:
|
||||
type: object
|
||||
required:
|
||||
- slot
|
||||
- fileId
|
||||
- fileName
|
||||
- contentType
|
||||
- sizeBytes
|
||||
- uploadedAt
|
||||
properties:
|
||||
slot:
|
||||
type: string
|
||||
enum:
|
||||
- FUEL_START
|
||||
- FUEL_END
|
||||
category:
|
||||
type: string
|
||||
enum:
|
||||
- FUEL
|
||||
fileId:
|
||||
type: string
|
||||
fileName:
|
||||
type: string
|
||||
contentType:
|
||||
type: string
|
||||
sizeBytes:
|
||||
type: string
|
||||
uploadedAt:
|
||||
type: string
|
||||
savedAt:
|
||||
type: string
|
||||
nullable: true
|
||||
Documents:
|
||||
type: object
|
||||
required:
|
||||
- dispatchNo
|
||||
- recordVersion
|
||||
- editable
|
||||
- photos
|
||||
properties:
|
||||
dispatchNo:
|
||||
type: string
|
||||
recordVersion:
|
||||
type: string
|
||||
editable:
|
||||
type: boolean
|
||||
photos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
BindPhoto:
|
||||
type: object
|
||||
required:
|
||||
- recordVersion
|
||||
- idempotencyKey
|
||||
- fileId
|
||||
properties:
|
||||
recordVersion:
|
||||
type: string
|
||||
pattern: ^(0|[1-9][0-9]*)$
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
fileId:
|
||||
type: string
|
||||
|
||||
@@ -3,12 +3,16 @@ info:
|
||||
title: 仓储转运上门取货与车辆排车
|
||||
version: 1.0.0
|
||||
description: |
|
||||
审核通过的 WAREHOUSE_PICKUP 外来转运单自动进入待取货候选;客户自送、库存转运、未审核及已完成测量单不进入。
|
||||
WAREHOUSE_PICKUP 外来转运单审核通过后,同事务自动生成绑定原单的 PICKUP 排车单(PC号),待分配车辆司机;客户自送、库存转运不自动排车。
|
||||
自动计划暂定为审核时刻发车、24小时后结束,并在备注标明待调度确认;不填写实际发车、实提或送仓事实。
|
||||
取货排车先维护车辆信息、提交调度审核,经调度和理货员同轮审核通过后进入装货中。逐票上传取货照片并确认实提,全车齐全后自动进入派送中;逐票上传独立到仓照片,全部到仓后自动完成取货派送,再继续原仓库收货流程。
|
||||
修改车辆、司机或计划时刻后本轮审核失效,须重新提交;审核不触发仓内拣货或库存扣减。
|
||||
重复审核沿原单锁复用已有任务,不重复建单,也不恢复已主动取消的任务;取消排车时对应转运单、尾程单、入仓单及待PDA计划同事务取消,不再进入取货候选。
|
||||
入仓取货和出库派送共用 ll_fleet_trip 及车辆司机占用,tripPurpose 分别为 PICKUP / DELIVERY,禁止混用库存出库命令、外接预订及余位发布。
|
||||
同车多单必须送往同一公共海外仓,原客户归属与转运单保留,不重复生成尾程单、不修改预报箱唛或库存。
|
||||
取货确认必须填写实提箱数并引用本票本车已上传的 PDF/JPG/PNG 凭证;送仓后开放 PDA 清点。
|
||||
司机实提箱数不替代仓库实收。全部送仓后结案运输,车辆和司机仍通过原 resources/release 分别结束任务。
|
||||
取消 PLANNED 车次原子释放资源和未执行取货关联,原单重新成为候选;已发车不可取消。
|
||||
取消 PLANNED 车次原子取消本车全部对应转运单、尾程单、入仓单及待PDA计划并释放资源;任一成员已有清点或收货事实则全部回滚,已发车不可取消。
|
||||
写命令具备车次版本及请求键,同键同内容重放不重复写入,同键不同内容拒绝。普通业务返回 HTTP 200 + body.code。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
@@ -79,7 +83,7 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}]
|
||||
post:
|
||||
summary: 逐票确认取货
|
||||
description: 仅平台主体,权限 platform:fleet:trip:edit。车次已发车且任务 ASSIGNED,核对本票本车上传归属后转 PICKED,实提箱数不覆盖预报。
|
||||
description: 仅平台主体,权限 platform:fleet:trip:edit。两级审核通过的计划车次或已出发车次,任务 ASSIGNED;核对本票本车图片上传归属后转 PICKED,实提箱数不覆盖预报。全车取货照片齐全后自动进入 IN_TRANSIT,排车阶段为 DELIVERING。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -98,7 +102,7 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}]
|
||||
post:
|
||||
summary: 确认本票送达海外仓
|
||||
description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;开放原 PDA 清点,不直接完成测量或入库。
|
||||
description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;evidenceFileIds 必填且为本票本车新上传的图片,不得复用取货照片。Evidence.phase 为 PICKUP 或 ARRIVAL 区分两次照片。全车均 ARRIVED 后自动完成运输,开放原 PDA 清点,不直接完成测量或入库。
|
||||
requestBody: {$ref: '#/components/requestBodies/command'}
|
||||
responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}}
|
||||
/api/platform/v1/last-leg/fleet/pickups/{tripNo}/complete:
|
||||
@@ -157,3 +161,4 @@ components:
|
||||
recordVersion: {type: integer, format: int64, minimum: 0}
|
||||
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
|
||||
reason: {type: string, maxLength: 1000}
|
||||
evidenceFileIds: {type: array, minItems: 1, maxItems: 10, items: {type: string}, description: 送仓确认必填,必须为本票本车新上传的到仓图片}
|
||||
|
||||
@@ -1728,8 +1728,8 @@ paths:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||
post:
|
||||
summary: 确认装车完成并自动进入派送中
|
||||
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
|
||||
summary: 确认装车完成,资料齐全时自动进入派送中
|
||||
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
@@ -5253,6 +5253,19 @@ components:
|
||||
type: object
|
||||
x-java-record: DispatchContracts.DriverEvidence
|
||||
properties:
|
||||
masterLicenseFileId:
|
||||
type: string
|
||||
pattern: ^[1-9][0-9]*$
|
||||
nullable: true
|
||||
description: 取货排车显式冻结的司机主档驾驶证ID,包含在响应fileIds中;其余取货证照为本车上传。普通派送沿既有MASTER证照语义。
|
||||
photoFileIds:
|
||||
type: array
|
||||
maxItems: 20
|
||||
uniqueItems: true
|
||||
description: 维护车辆司机时保存的本趟司机照片,按DRIVER_PHOTO类别校验,与驾驶证附件独立。
|
||||
items:
|
||||
type: string
|
||||
pattern: ^[1-9][0-9]*$
|
||||
licenseNo:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
@@ -253,8 +253,8 @@ paths:
|
||||
type: string
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||
post:
|
||||
summary: 确认装车完成并自动进入派送中
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
|
||||
summary: 确认装车完成,资料齐全时自动进入派送中
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
|
||||
+21
-2
@@ -86,6 +86,25 @@ public class DispatchAggregatePersistenceTest {
|
||||
verify(e.files,never()).download(anyLong(),anyLong());verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 取货已保存的补传证照保持本车归属,只有明确主档引用可以读取司机证照。 */
|
||||
@Test void pickupSavedLicenseUploadsDoNotBecomeMasterLicenseReferences() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L,91L));
|
||||
var row=e.f.repository.find(number);
|
||||
var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class);
|
||||
var d=resources.driver();var proof=d.evidence();
|
||||
var next=new DispatchContracts.Resources(resources.vehicle(),new DispatchContracts.DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),
|
||||
new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)));
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP', resources_json=? WHERE dispatch_no=?",e.f.json.writeValueAsString(next),number);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var uploaded=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"pickup.png","image/png",3L);
|
||||
when(e.dispatchFiles.metadata(row.getId(),kind,91L)).thenReturn(uploaded);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).isEqualTo(uploaded);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
e.allowFile(81L);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,81L).fileId()).isEqualTo(81L);
|
||||
verify(e.files).metadata(71L,81L);
|
||||
}
|
||||
|
||||
/** MASTER改外配的编辑尚未保存时,新上传文件只走本车归属,不误借旧司机白名单。 */
|
||||
@Test void unsavedExternalUploadOnMasterDispatchRetainsOriginalDispatchFileReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
@@ -148,7 +167,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
|
||||
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"))));
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())));
|
||||
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
|
||||
}
|
||||
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
|
||||
@@ -591,7 +610,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方",
|
||||
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
|
||||
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400))),
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())),
|
||||
departure,null,null,sources,List.of());
|
||||
}
|
||||
|
||||
|
||||
+135
-21
@@ -1,6 +1,15 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.BindPhoto;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Documents;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import java.nio.file.*;
|
||||
import java.util.regex.Pattern;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
@@ -19,43 +28,53 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */
|
||||
/** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */
|
||||
class DispatchLoadingDeparturePersistenceTest {
|
||||
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
|
||||
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
|
||||
|
||||
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */
|
||||
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception {
|
||||
/** 装车完成不发车,第五张保存自动发车,旧装车键保持原装车后结果。 */
|
||||
@Test void loadingWaitsForFivePhotosThenDepartsOnce() throws Exception {
|
||||
authorize();var f=new Fixture();
|
||||
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
|
||||
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
|
||||
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
|
||||
for(int i=0;i<4;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("AFTER_LOADING");
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
|
||||
var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING");
|
||||
assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5);
|
||||
var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.RECEIPT,new BindPhoto("4","photo-bind-4","1004")));
|
||||
assertThat(replayPhoto).isEqualTo(fifth);
|
||||
assertThat(result.counts().shippedPackageCount()).isZero();assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
|
||||
assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED");
|
||||
assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING");
|
||||
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
|
||||
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING");
|
||||
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("AFTER_LOADING");
|
||||
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
|
||||
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING);
|
||||
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART);
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段");
|
||||
}
|
||||
|
||||
/** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */
|
||||
@Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception {
|
||||
/** 派送失败回滚第五张绑定,保留已确认装车和前四张资料,原文件可重试。 */
|
||||
@Test void preparationAndDepartureFailuresRollBackTheFifthPhoto() throws Exception {
|
||||
authorize();
|
||||
for(boolean failPreparation:List.of(true,false)) {
|
||||
var f=new Fixture();
|
||||
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
|
||||
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
|
||||
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class);
|
||||
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version);
|
||||
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
|
||||
assertThatThrownBy(()->f.bind(4,f.f.actor)).isInstanceOf(IllegalStateException.class);
|
||||
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("AFTER_LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version+1);
|
||||
assertThat(row.getLoadingCompletionHash()).isNotNull();
|
||||
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(4);assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
|
||||
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
}
|
||||
@@ -68,12 +87,12 @@ class DispatchLoadingDeparturePersistenceTest {
|
||||
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
|
||||
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
|
||||
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
|
||||
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
|
||||
}
|
||||
|
||||
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */
|
||||
/** 同一版本后台/PDA同时确认只有一次装车提交,资料未齐不发车。 */
|
||||
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
|
||||
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
@@ -81,20 +100,99 @@ class DispatchLoadingDeparturePersistenceTest {
|
||||
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
|
||||
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
|
||||
} finally {workers.shutdownNow();}
|
||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("AFTER_LOADING");
|
||||
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any());
|
||||
}
|
||||
|
||||
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */
|
||||
/** 装车中可上传,装车前与跨仓拒绝,平台只读权限不能保存照片。 */
|
||||
@Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception {
|
||||
authorize();var f=new Fixture();
|
||||
assertThat(f.documents.get(f.f.actor,f.number).stage()).isEqualTo("LOADING");
|
||||
f.f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING'");
|
||||
assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("进入装车中后");
|
||||
f.f.db.update("UPDATE ll_dispatch_order SET stage='LOADING'");
|
||||
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
assertThatThrownBy(()->f.documents.get(new Actor("picker",ActorMode.PDA,"99"),f.number)).hasMessageContaining("当前绑定仓");
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,null,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框");
|
||||
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("reader","","platform:overseas-warehouse:dispatch:list"));
|
||||
assertThat(f.documents.get(f.f.actor,f.number).editable()).isFalse();
|
||||
assertThatThrownBy(()->f.bind(0,f.f.actor)).hasMessageContaining("无装车资料操作权限");
|
||||
}
|
||||
|
||||
/** 两端同版本竞争第五张,仅一个绑定和一次真实派送成功。 */
|
||||
@Test void concurrentFifthPhotoCannotDispatchTwice() throws Exception {
|
||||
authorize();var f=new Fixture();f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
|
||||
var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var results=new ArrayList<Future<Boolean>>();
|
||||
for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{
|
||||
authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor;
|
||||
f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,new BindPhoto("4",key,"1004")));return true;
|
||||
}catch(com.baiyue.common.core.exception.BizException expected){return false;}
|
||||
finally {SecurityContextHolder.clearContext();}
|
||||
}));
|
||||
start.countDown();assertThat(List.of(results.get(0).get(25,TimeUnit.SECONDS),results.get(1).get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
|
||||
} finally {workers.shutdownNow();}
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
|
||||
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5);
|
||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
|
||||
}
|
||||
|
||||
/** 资料先齐不能提前发车;确认装车后直接派送,原键返回相同最终结果。 */
|
||||
@Test void photosReadyBeforeLoadingConfirmationDepartAndReplayOnce() throws Exception {
|
||||
authorize();var f=new Fixture();
|
||||
for(int i=0;i<5;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("LOADING");
|
||||
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
|
||||
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-complete")));
|
||||
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);
|
||||
assertThat(result.stage()).isEqualTo(Stage.DELIVERING);assertThat(result.counts().shippedPackageCount()).isEqualTo(8);
|
||||
assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
|
||||
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-complete")));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.DELIVERING);
|
||||
assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
|
||||
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"ready-complete")).stage()).isEqualTo(Stage.DELIVERING);
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
|
||||
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
}
|
||||
|
||||
/** 资料先齐时派送失败回滚本次装车确认,五张已保存照片保留供原请求重试。 */
|
||||
@Test void readyDocumentsSurviveFailedLoadingAndOriginalRequestCanRetry() throws Exception {
|
||||
authorize();var f=new Fixture();for(int i=0;i<5;i++)f.bind(i,f.f.actor);
|
||||
doThrow(new IllegalStateException("模拟发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("retry-ready")))).hasMessageContaining("模拟发运失败");
|
||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("LOADING");
|
||||
assertThat(f.documents.get(f.f.actor,f.number).complete()).isTrue();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='retry-ready'",Long.class)).isZero();
|
||||
doNothing().when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
|
||||
assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("retry-ready"))).stage()).isEqualTo(Stage.DELIVERING);
|
||||
}
|
||||
|
||||
/** 最后一张照片与装车确认同时提交,不论谁先拿父锁均只发车一次。 */
|
||||
@Test void lastPhotoAndLoadingConfirmationCanFinishConcurrently() throws Exception {
|
||||
authorize();var f=new Fixture();for(int i=0;i<4;i++)f.bind(i,f.f.actor);
|
||||
var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var load=workers.submit(()->f.tryComplete(start,f.f.actor,"concurrent-load"));
|
||||
var photo=workers.submit(()->{authorize();try {start.await(5,TimeUnit.SECONDS);return f.bind(4,new Actor("picker",ActorMode.PDA,"9"));}finally{SecurityContextHolder.clearContext();}});
|
||||
start.countDown();assertThat(load.get(25,TimeUnit.SECONDS)).isTrue();assertThat(photo.get(25,TimeUnit.SECONDS)).isNotNull();
|
||||
} finally {workers.shutdownNow();}
|
||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
|
||||
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
}
|
||||
|
||||
/** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */
|
||||
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
|
||||
"platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
|
||||
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
|
||||
|
||||
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
|
||||
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
|
||||
final DispatchLoadingDepartureService service;final String number;final long version;final String manifest;
|
||||
final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest;
|
||||
final List<LoadingMeasurement> measurements;
|
||||
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
|
||||
Fixture() throws Exception {
|
||||
@@ -126,7 +224,23 @@ class DispatchLoadingDeparturePersistenceTest {
|
||||
var hashes=new DispatchCommandHasher(f.json);
|
||||
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
|
||||
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
|
||||
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes);
|
||||
f.session.getConfiguration().addMapper(OwnedBusinessMapper.class);
|
||||
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for(String suffix:List.of("record","command","history")) {
|
||||
var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema);
|
||||
assertThat(matcher.find()).isTrue();
|
||||
f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";"));
|
||||
}
|
||||
var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class));
|
||||
var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class);
|
||||
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z"));
|
||||
var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files);
|
||||
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos);
|
||||
documents=new DispatchLoadingDocumentService(f.service,service,photos);
|
||||
}
|
||||
/** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */
|
||||
Documents bind(int index,Actor actor) {
|
||||
return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index))));
|
||||
}
|
||||
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
|
||||
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
|
||||
|
||||
+63
-1
@@ -23,6 +23,44 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
|
||||
class DispatchPickingPersistenceTest {
|
||||
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
|
||||
@Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
|
||||
new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null))))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用");
|
||||
verify(fixture.inventory,never()).ship(any(),any(),any());
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
}
|
||||
/** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */
|
||||
@Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null);
|
||||
var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED");
|
||||
var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
|
||||
assertThat(replay.replayed()).isTrue();
|
||||
var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem,
|
||||
new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
|
||||
verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any());
|
||||
assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */
|
||||
@Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(8,11);
|
||||
doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any());
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
|
||||
new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
|
||||
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version);
|
||||
}
|
||||
/** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */
|
||||
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
|
||||
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
|
||||
@@ -68,6 +106,7 @@ class DispatchPickingPersistenceTest {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
|
||||
final DispatchPickingService service;final String number;final long item,version;final Long secondItem;
|
||||
final DispatchSourceRepository sources;
|
||||
final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
/** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */
|
||||
Picking() throws Exception {this(false);}
|
||||
@@ -94,10 +133,33 @@ class DispatchPickingPersistenceTest {
|
||||
doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any());
|
||||
doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));})
|
||||
.when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull());
|
||||
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
|
||||
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
|
||||
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
|
||||
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
|
||||
for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet);
|
||||
}
|
||||
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
|
||||
void receivedPlans(int first,int second) {
|
||||
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN',package_count=10");
|
||||
f.db.update("UPDATE ll_dispatch_order SET package_count=20");
|
||||
var rules=(DispatchService)org.springframework.test.util.ReflectionTestUtils.getField(service,"sourceRules");
|
||||
for(int n=1;n<=2;n++) {
|
||||
long plan=n,pallet=500+n;String customer=n==1?"101":"102";int count=n==1?first:second;
|
||||
f.candidates.get(plan).packageCount(count);
|
||||
f.db.update("UPDATE ll_inventory_position SET available_packages=? WHERE id=?",count,10+n);
|
||||
doReturn(List.of(f.candidates.get(plan))).when(sources).selectedPlan(customer,plan);
|
||||
doReturn(true).when(sources).receivedPlanCompleted(customer,plan);
|
||||
doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,null,plan);
|
||||
doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,pallet,plan);
|
||||
doReturn(plan).when(sources).lockPlan(customer,plan);
|
||||
doReturn(1).when(sources).materialize(anyLong(),eq(customer),eq(plan),eq(pallet),anyLong(),any());
|
||||
}
|
||||
when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{
|
||||
var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();var row=f.candidates.get(plan);
|
||||
return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
selected,"PLAN:"+plan,row.customerId(),row.customerName(),"INBOUND_PLAN","PLAN-"+plan,row.packageCount())),List.of(),true,List.of());
|
||||
});
|
||||
}
|
||||
/** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */
|
||||
private static String sqlType(Class<?> type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";}
|
||||
|
||||
+58
-7
@@ -17,6 +17,22 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */
|
||||
class DispatchPlanPdaReadPersistenceTest {
|
||||
/** 完成状态查询走真实Mapper,未完成或其他客户的计划不能被当作本托实收。 */
|
||||
@Test void completionGateUsesOriginalCustomerAndPlan() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),status VARCHAR(64))");
|
||||
f.f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR(64))");
|
||||
f.f.db.update("INSERT INTO ll_inbound_plan VALUES(1,'101','IN_PROGRESS')");
|
||||
f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')");
|
||||
var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class);
|
||||
assertThat(mapper.receivedPlanCompleted("101",2L)).isZero();
|
||||
for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) {
|
||||
f.f.db.update("UPDATE ll_inbound_plan SET status=?",status);
|
||||
assertThat(mapper.receivedPlanCompleted("101",2L)).isOne();
|
||||
assertThat(mapper.receivedPlanCompleted("other",2L)).isZero();
|
||||
assertThat(mapper.receivedPlanCompleted("101",3L)).isZero();
|
||||
}
|
||||
}
|
||||
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
|
||||
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
|
||||
var f=new Fixture();var before=f.dbState();
|
||||
@@ -28,15 +44,23 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
|
||||
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
|
||||
}
|
||||
/** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */
|
||||
/** 在途和来源身份变化均不成为可拣库存。 */
|
||||
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
|
||||
var f=new Fixture();var row=f.f.candidates.get(1L);
|
||||
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */
|
||||
@Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception {
|
||||
var f=new Fixture(true);var before=f.dbState();
|
||||
var task=f.read();
|
||||
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L);
|
||||
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty());
|
||||
assertThat(task.counts().packageCount()).isEqualTo(20);
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
}
|
||||
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
|
||||
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
|
||||
var f=new Fixture();
|
||||
@@ -53,11 +77,18 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
|
||||
var f=new Fixture();
|
||||
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
|
||||
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) {
|
||||
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1),
|
||||
new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) {
|
||||
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
}
|
||||
}
|
||||
/** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */
|
||||
@Test void receivingPlanDoesNotExposePartialPallet() throws Exception {
|
||||
var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false);
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
@@ -65,9 +96,13 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
final DispatchService rules=mock(DispatchService.class);
|
||||
final DispatchPdaQueryService queries;final String number;
|
||||
/** 原车已审核,只清除自然收货尚未补写的实托字段。 */
|
||||
Fixture() throws Exception {
|
||||
var selection=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection))));
|
||||
Fixture() throws Exception {this(false);}
|
||||
/** 同一客户两托先预排再实收,模拟计划拆分与真实入托数量不同。 */
|
||||
Fixture(boolean twoPallets) throws Exception {
|
||||
var selection=f.source(1,"101",501,"ONT8",twoPallets?10:3);
|
||||
var selections=new java.util.ArrayList<DispatchCommands.CargoSelection>();selections.add(selection);
|
||||
if(twoPallets)selections.add(f.source(2,"101",502,"ONT8",10));
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",selections)));
|
||||
f.approve(created);number=created.dispatchNo();
|
||||
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'");
|
||||
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
|
||||
@@ -76,12 +111,28 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
|
||||
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
|
||||
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
|
||||
when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true);
|
||||
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
|
||||
when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
|
||||
when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1)));
|
||||
when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
|
||||
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of()));
|
||||
if(twoPallets) {
|
||||
for(int n=1;n<=2;n++) {
|
||||
long plan=n,pallet=500+n;int count=n==1?9:11;
|
||||
f.candidates.get(plan).packageCount(count);
|
||||
when(sources.selectedPlan("101",plan)).thenReturn(List.of(f.candidates.get(plan)));
|
||||
when(sources.sourceOrders("101",null,plan)).thenReturn(List.of(9000+plan));
|
||||
when(sources.sourceOrders("101",pallet,plan)).thenReturn(List.of(9000+plan));
|
||||
when(sources.stock("101",pallet,false)).thenReturn(List.of(new Stock(10+n,20+n,pallet,"9",count,0,0,0,1)));
|
||||
}
|
||||
when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{
|
||||
var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();
|
||||
return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
selected,"PLAN:"+plan,"101","测试客户","INBOUND_PLAN","PLAN-"+plan,plan==1?9:11)),List.of(),true,List.of());
|
||||
});
|
||||
}
|
||||
}
|
||||
/** 读取正式PDA入口。 */
|
||||
DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));}
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ class DispatchRepairWorkflowTest {
|
||||
assertThat(created.dispatchNo()).isNotEqualTo("DP-1");
|
||||
var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo());
|
||||
assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue();
|
||||
assertThat(current.actions().get(DispatchTypes.Action.ARRANGE_DELIVERY).allowed()).isFalse();
|
||||
assertThat(current.actions()).doesNotContainKey(DispatchTypes.Action.ARRANGE_DELIVERY);
|
||||
assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId());
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/** 车辆维护保存司机照片后,真实资源快照、审核和原键恢复均保留相同引用。 */
|
||||
class DispatchResourcePhotosPersistenceTest {
|
||||
/** 新字段缺省不改变升级前命令及审核依据的摘要,实际照片变化仍必须识别。 */
|
||||
@Test void optionalPhotosPreserveLegacyHashesButBindActualReferences() throws Exception {
|
||||
var f = new DispatchAggregatePersistenceTest.Fixture();
|
||||
var input = f.configured("legacy-photos", List.of());
|
||||
var request = update(input, 0L, "legacy-edit", List.of());
|
||||
var oldJson = f.json.valueToTree(request);
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) oldJson.path("driver").path("evidence")).remove("photoFileIds");
|
||||
var hashes = new DispatchCommandHasher(f.json);
|
||||
var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.EDIT;
|
||||
var oldHash = hashes.command(f.actor, action, "PC-OLD", 0L, oldJson);
|
||||
assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, request)).isEqualTo(oldHash);
|
||||
assertThat(hashes.source(request)).isEqualTo(hashes.source(oldJson));
|
||||
var withPhotos = update(input, 0L, "legacy-edit", List.of(401L, 402L));
|
||||
assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, withPhotos)).isNotEqualTo(oldHash)
|
||||
.isEqualTo(hashes.command(f.actor, action, "PC-OLD", 0L, update(input, 0L, "legacy-edit", List.of(402L, 401L))));
|
||||
}
|
||||
|
||||
/** 使用真实资源持久化与两级审核,防止车队资源规范化吞掉独立司机照片。 */
|
||||
@Test void savedPhotosSurviveReplayAndBothReviews() throws Exception {
|
||||
var f = new DispatchAggregatePersistenceTest.Fixture();
|
||||
var input = f.configured("photos-create", List.of(f.source(1, "101", 501, "ONT8", 3)));
|
||||
var created = f.tx.execute(t -> f.service.create(f.actor, input));
|
||||
var request = update(input, created.recordVersion(), "photos-save", List.of(401L));
|
||||
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request));
|
||||
assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue();
|
||||
var approved = f.approve(saved);
|
||||
var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo()));
|
||||
assertThat(detail.vehicleInfo().driver().evidence().photoFileIds()).containsExactly(401L);
|
||||
assertThat(detail.vehicleInfo().driver().evidence().fileIds()).containsExactly(202L);
|
||||
verify(gateway(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L);
|
||||
assertThat(approved.recordVersion()).isGreaterThan(saved.recordVersion());
|
||||
}
|
||||
|
||||
/** 错车或错类附件失败时,事务不得覆盖原资源、推进版本或留下成功命令。 */
|
||||
@Test void foreignPhotoCannotChangeSavedResources() throws Exception {
|
||||
var f = new DispatchAggregatePersistenceTest.Fixture();
|
||||
var input = f.configured("foreign-photo-create", List.of(f.source(1, "101", 501, "ONT8", 3)));
|
||||
var created = f.tx.execute(t -> f.service.create(f.actor, input));
|
||||
var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo());
|
||||
doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway(f))
|
||||
.metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L);
|
||||
var request = update(input, created.recordVersion(), "foreign-photo-save", List.of(401L));
|
||||
assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)))
|
||||
.hasMessageContaining("归属不匹配");
|
||||
assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='foreign-photo-save'", Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 编辑清空照片必须真实持久化;历史资源缺该字段时仍按空集合读取。 */
|
||||
@Test void photosCanBeRemovedAndOldSnapshotsRemainReadable() throws Exception {
|
||||
var f = new DispatchAggregatePersistenceTest.Fixture();
|
||||
var input = f.configured("remove-photo-create", List.of());
|
||||
var created = f.tx.execute(t -> f.service.create(f.actor, input));
|
||||
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, created.recordVersion(), "add-photo", List.of(401L))));
|
||||
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, saved.recordVersion(), "remove-photo", List.of())));
|
||||
assertThat(f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())).vehicleInfo().driver().evidence().photoFileIds()).isEmpty();
|
||||
var legacy = f.json.readValue("{\"licenseNo\":\"TEST\",\"fileIds\":[202]}", DispatchContracts.DriverEvidence.class);
|
||||
assertThat(legacy.photoFileIds()).isEmpty();
|
||||
assertThat(legacy.fileIds()).containsExactly(202L);
|
||||
}
|
||||
|
||||
/** 仅替换司机照片,保留车辆、驾驶证和计划字段。 */
|
||||
private static DispatchCommands.Update update(DispatchCommands.Create input, long version, String key, List<Long> photos) {
|
||||
var d = input.driver();
|
||||
var e = d.evidence();
|
||||
var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(),
|
||||
new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos));
|
||||
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片");
|
||||
}
|
||||
|
||||
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
|
||||
private static DispatchEvidenceGateway gateway(DispatchAggregatePersistenceTest.Fixture f) {
|
||||
return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence");
|
||||
}
|
||||
}
|
||||
+26
@@ -11,6 +11,32 @@ import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
|
||||
class DispatchReviewWorkflowPersistenceTest {
|
||||
/** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */
|
||||
@Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pickup-review",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",created.dispatchNo());
|
||||
var transport=org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class);
|
||||
var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setStopsJson("[]");
|
||||
org.mockito.Mockito.when(transport.transport(org.mockito.ArgumentMatchers.eq(created.dispatchNo()),org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(trip);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport);
|
||||
var submitted=submit(f,created,"pickup-submit");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
|
||||
assertThatThrownBy(()->review(f,f.actor,submitted,true,"skip",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
|
||||
var approved=review(f,f.actor,submitted,false,"pickup-dispatch",Decision.APPROVED,null);
|
||||
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW);
|
||||
var rejected=review(f,f.actor,approved,true,"pickup-reject",Decision.REJECTED,"车辆不符");
|
||||
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
|
||||
var next=submit(f,rejected,"pickup-resubmit");
|
||||
var nextApproved=review(f,f.actor,next,false,"pickup-dispatch-2",Decision.APPROVED,null);
|
||||
var loaded=review(f,f.actor,nextApproved,true,"pickup-loading-2",Decision.APPROVED,null);
|
||||
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
|
||||
var row=f.repository.find(created.dispatchNo());
|
||||
assertThat(row.getReviewState()).isEqualTo("APPROVED");
|
||||
assertThat(f.service.actions(f.actor,row).get(Action.PICK).allowed()).isFalse();
|
||||
assertThat(f.service.actions(f.actor,row).get(Action.LOADING_COMPLETE).allowed()).isFalse();
|
||||
assertThat(f.repository.outbounds(row.getId())).isEmpty();
|
||||
org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(5)).verifyPickupReview(created.dispatchNo());
|
||||
}
|
||||
/** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */
|
||||
@Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
||||
import com.baiyue.lastleg.fleet.driver.*;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class TransportReviewPhotosTest {
|
||||
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
final DispatchEvidenceGateway files=mock(DispatchEvidenceGateway.class);
|
||||
final DriverService drivers=mock(DriverService.class);
|
||||
final TransportReviewPhotos service=new TransportReviewPhotos(files,drivers,json);
|
||||
final DispatchAggregate row=new DispatchAggregate();
|
||||
final FleetTripEntity trip=new FleetTripEntity();
|
||||
/** 使用已审核海外司机及未来任务时间,不依赖运行中的服务。 */
|
||||
@BeforeEach void setup() {
|
||||
row.setId(10L);row.setRecordVersion(9L);
|
||||
trip.setVehicleId(1L);trip.setDriverId(2L);trip.setRecordVersion(4L);trip.setExpectedDriverEndAt(LocalDateTime.of(2099,1,1,0,0));
|
||||
var response=mock(DriverModels.Response.class,RETURNS_DEEP_STUBS);
|
||||
when(response.details().region()).thenReturn("OVERSEAS");when(response.details().licenseNumber()).thenReturn("TEST");
|
||||
when(response.details().licenseExpiresOn()).thenReturn(LocalDate.of(2099,2,1));when(response.licenseFileId()).thenReturn("33");
|
||||
when(drivers.requireDispatchable(eq(2L),any())).thenReturn(response);
|
||||
}
|
||||
/** 必填照片不足时不能调用文件或资源保存。 */
|
||||
@Test void requiresAllThreeCategories() {
|
||||
for(var photos:List.of(new ReviewAttachments(List.of(),List.of(22L),List.of(23L)),
|
||||
new ReviewAttachments(List.of(21L),List.of(),List.of(23L)),new ReviewAttachments(List.of(21L),List.of(22L),List.of()))) {
|
||||
assertThatThrownBy(()->service.validate(row,trip,request(photos,null))).hasMessageContaining("补齐");
|
||||
}
|
||||
verifyNoInteractions(files,drivers);
|
||||
}
|
||||
/** 主档与本车补传走各自归属,禁止拿别人的证照冒充当前司机。 */
|
||||
@Test void distinguishesMasterLicenseFromDispatchUploads() {
|
||||
var evidence=service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of()),33L));
|
||||
assertThat(evidence.fileIds()).containsExactly(33L);assertThat(evidence.masterLicenseFileId()).isEqualTo(33L);
|
||||
verify(drivers).download(2L,33L);verify(files,never()).metadata(10L,DRIVER,33L);
|
||||
var uploaded=service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(23L)),null));
|
||||
assertThat(uploaded.masterLicenseFileId()).isNull();verify(files).metadata(10L,DRIVER,23L);
|
||||
assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of()),99L))).hasMessageContaining("主档驾驶证已变化");
|
||||
}
|
||||
/** 同车不同分类及跨排车文件由文件网关拒绝。 */
|
||||
@Test void rejectsForeignOrWrongCategoryPhoto() {
|
||||
when(files.metadata(10L,DRIVER_PHOTO,22L)).thenThrow(new IllegalArgumentException("文件归属错误"));
|
||||
assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(23L)),null))).hasMessageContaining("归属错误");
|
||||
}
|
||||
/** 换司机后旧人像不允许重复绑定。 */
|
||||
@Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception {
|
||||
row.setResourcesJson(json.writeValueAsString(new Resources(null,new DriverSnapshot(ResourceMode.MASTER,5L,"原司机","000",
|
||||
new DriverEvidence(null,null,List.of(23L),null,List.of(22L))))));
|
||||
assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(24L)),null))).hasMessageContaining("重新上传");
|
||||
}
|
||||
/** 审核复验已保存证据,照片后续失效也不能通过。 */
|
||||
@Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception {
|
||||
row.setResourcesJson(json.writeValueAsString(new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,null,null,List.of(21L)),
|
||||
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L))))));
|
||||
service.verifyReview(row,trip);verify(files).metadata(10L,VEHICLE,21L);
|
||||
when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效"));
|
||||
assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效");
|
||||
}
|
||||
/** 构造同一车辆、司机的双版本保存请求。 */
|
||||
private TransportTaskModels.Assign request(ReviewAttachments photos,Long master) {
|
||||
return new TransportTaskModels.Assign(4L,1L,2L,"维护照片",9L,photos,master);
|
||||
}
|
||||
}
|
||||
+50
@@ -15,6 +15,56 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class TransportTaskProjectionTest {
|
||||
/** 照片写入使审核失效,后续出发、到仓、结案均保留同一资源的照片。 */
|
||||
@Test void photosInvalidateReviewAndSurviveExecutionProjection() throws Exception {
|
||||
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),json);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(projection,"pickupFacts",mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class));
|
||||
var row=new DispatchAggregate();row.setId(2L);row.setDispatchNo("PC1");row.setTaskType("PICKUP");row.setStatus("ACTIVE");row.setRecordVersion(10L);row.setReviewRound(1L);row.setReviewState("APPROVED");row.setStage("LOADING");
|
||||
var oldReview=new com.baiyue.lastleg.dispatch.port.DispatchContracts.Review(com.baiyue.lastleg.dispatch.port.DispatchTypes.ReviewStatus.APPROVED,null,"OP",java.time.Instant.now(),1,"basis",null,null);
|
||||
row.setDispatchReviewJson(json.writeValueAsString(oldReview));row.setLoadingReviewJson(json.writeValueAsString(oldReview));
|
||||
row.setResourcesJson("{\"vehicle\":{\"mode\":\"MASTER\",\"vehicleId\":7,\"evidenceFileIds\":[]},\"driver\":{\"mode\":\"MASTER\",\"driverId\":8}}");
|
||||
when(roots.lock("PC1")).thenReturn(row);when(roots.saveState(eq(row),anyLong())).thenReturn(1);when(links.byTrip(1L)).thenReturn(row);
|
||||
var photos=new com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence("LICENSE",java.time.LocalDate.of(2099,1,1),java.util.List.of(33L),java.time.Instant.parse("2098-01-01T00:00:00Z"),java.util.List.of(22L),33L);
|
||||
projection.savePhotos("PC1","OP",java.util.List.of(21L),photos);
|
||||
assertThat(row.getReviewState()).isEqualTo("DRAFT");assertThat(row.getRecordVersion()).isEqualTo(11L);assertThat(row.getStage()).isEqualTo("BEFORE_LOADING");
|
||||
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("PICKUP");trip.setVehicleId(7L);trip.setDriverId(8L);trip.setStatus("IN_TRANSIT");
|
||||
projection.changed(new TransportTripChanged(trip,"PICKUP_PICKED","装货","OP"));
|
||||
trip.setStatus("COMPLETED");projection.changed(new TransportTripChanged(trip,"PICKUP_ARRIVED","到仓","OP"));
|
||||
var saved=json.readValue(row.getResourcesJson(),com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources.class);
|
||||
assertThat(saved.vehicle().evidenceFileIds()).containsExactly(21L);assertThat(saved.driver().evidence()).isEqualTo(photos);
|
||||
assertThat(row.getStage()).isEqualTo("DELIVERED");
|
||||
}
|
||||
/** 已出发也不能跳过照片阶段,全部取货照片确认后才进入派送中。 */
|
||||
@Test void oldDepartureDoesNotSkipPickupPhotosStage() {
|
||||
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var pickups=mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
|
||||
var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules());
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(projection,"pickupFacts",pickups);
|
||||
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setRecordVersion(10L);root.setStage("LOADING");
|
||||
var task=new com.baiyue.lastleg.fleet.pickup.FleetPickupTask();task.setStatus("ASSIGNED");
|
||||
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(eq(root),anyLong())).thenReturn(1);when(pickups.tasks("TR1")).thenReturn(java.util.List.of(task));
|
||||
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");
|
||||
projection.changed(new TransportTripChanged(trip,"PICKUP_DEPART","出发","OP"));assertThat(root.getStage()).isEqualTo("LOADING");
|
||||
task.setStatus("PICKED");projection.changed(new TransportTripChanged(trip,"PICKUP_PICKED","照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERING");
|
||||
trip.setStatus("COMPLETED");projection.changed(new TransportTripChanged(trip,"PICKUP_ARRIVED","到仓照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERED");
|
||||
}
|
||||
/** 审核推进后车次版本可能较小,换车仍须递增主单版本并作废两级审核。 */
|
||||
@Test void pickupResourceChangeInvalidatesApprovalWithoutRegressingVersion() throws Exception {
|
||||
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),json);
|
||||
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setStatus("ACTIVE");root.setStage("LOADING");root.setRecordVersion(10L);root.setReviewRound(1L);root.setReviewState("APPROVED");root.setReviewBasisHash("basis");root.setResourcesJson("{}");
|
||||
var review=new com.baiyue.lastleg.dispatch.port.DispatchContracts.Review(com.baiyue.lastleg.dispatch.port.DispatchTypes.ReviewStatus.APPROVED,null,"OP",java.time.Instant.now(),1,"basis",null,null);
|
||||
root.setDispatchReviewJson(json.writeValueAsString(review));root.setLoadingReviewJson(json.writeValueAsString(review));
|
||||
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,10L)).thenReturn(1);
|
||||
assertThatCode(()->projection.requireApproved(1L)).doesNotThrowAnyException();
|
||||
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now());
|
||||
projection.changed(new TransportTripChanged(trip,"ASSIGN","换车","OP"));
|
||||
assertThat(root.getRecordVersion()).isEqualTo(11L);assertThat(root.getReviewState()).isEqualTo("DRAFT");assertThat(root.getStage()).isEqualTo("BEFORE_LOADING");
|
||||
assertThatThrownBy(()->projection.requireApproved(1L)).hasMessageContaining("调度审核和理货员审核");
|
||||
verify(roots).saveState(root,10L);
|
||||
}
|
||||
/** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */
|
||||
@Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){
|
||||
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class);
|
||||
|
||||
+32
@@ -22,6 +22,14 @@ class TransportTaskServiceTest {
|
||||
DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips;
|
||||
UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order;
|
||||
final Map<String,String> commands=new HashMap<>();
|
||||
/** 审核已改变的旧页面不能通过仍相同的车次版本覆盖资源和照片。 */
|
||||
@Test void staleDispatchVersionCannotAssignEvenWhenTripVersionMatches() {
|
||||
var root=roots.find("PC1");root.setTaskType("PICKUP");root.setRecordVersion(9L);when(roots.lock("PC1")).thenReturn(root);
|
||||
trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED");
|
||||
var photos=mock(TransportReviewPhotos.class);org.springframework.test.util.ReflectionTestUtils.setField(service,"reviewPhotos",photos);
|
||||
assertThatThrownBy(()->service.assign("PC1","OP",new TransportTaskModels.Assign(3L,7L,8L,"保存",8L,null,null))).hasMessageContaining("刷新");
|
||||
verify(trips,never()).assign(anyString(),anyString(),any());verifyNoInteractions(photos);
|
||||
}
|
||||
@BeforeEach void setup(){
|
||||
roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class);
|
||||
trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class);
|
||||
@@ -58,6 +66,30 @@ class TransportTaskServiceTest {
|
||||
@Test void evidenceDownloadRequiresPersistedConfirmation(){
|
||||
assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files);
|
||||
}
|
||||
/** 只对审核通过的上门取货生成排车,已有及取消任务都不得重建。 */
|
||||
@Test void approvalCreatesOnlyEligibleUnassignedPickup() {
|
||||
var facts=mock(FleetPickupRepository.class);
|
||||
var subject=spy(new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,
|
||||
mock(FleetPickupService.class),facts,trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),
|
||||
mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules()));
|
||||
var source=new FleetPickupSource();source.setId(42L);source.setTransferNo("ZY1");source.setInboundCode("ZY1");
|
||||
source.setDeliveryMethod("WAREHOUSE_PICKUP");source.setStatus("WAIT_PDA_RECEIVING");
|
||||
when(facts.source("C1","ZY1",true)).thenReturn(source);
|
||||
doReturn(null).when(subject).createPickup(anyString(),any());
|
||||
subject.ensureApprovedPickup("C1","ZY1","OP");
|
||||
var request=org.mockito.ArgumentCaptor.forClass(FleetPickupModels.Create.class);
|
||||
verify(subject).createPickup(eq("OP"),request.capture());
|
||||
assertThat(request.getValue().sources()).containsExactly(new FleetPickupModels.Source("C1","ZY1"));
|
||||
assertThat(request.getValue().idempotencyKey()).isEqualTo("transfer-approval:42");
|
||||
assertThat(request.getValue().expectedDriverEndAt()).isAfter(request.getValue().plannedDepartureAt());
|
||||
clearInvocations(subject);
|
||||
when(facts.lockTask(42L)).thenReturn(new FleetPickupTask());
|
||||
subject.ensureApprovedPickup("C1","ZY1","OP2");
|
||||
verify(subject,never()).createPickup(anyString(),any());
|
||||
source.setDeliveryMethod("CUSTOMER_DELIVERY");clearInvocations(facts);
|
||||
subject.ensureApprovedPickup("C1","ZY1","OP");
|
||||
verify(facts,never()).lockTask(anyLong());
|
||||
}
|
||||
/** 构造固定版本的测试执行命令。 */
|
||||
private TransportTaskModels.Command command(String key,List<Long> files){return new TransportTaskModels.Command(3L,key,"结束运输",files);}
|
||||
}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.controller.DispatchVehicleDocumentController;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 排车资料复用同一车辆照片记录,不扩大旧入口或改变履约状态。 */
|
||||
class DispatchVehicleDocumentTest {
|
||||
/** 无拼车和实际拼车都能在派送后读取;原车队分类和冻结装车门禁继续生效。 */
|
||||
@Test void dispatchScopeAcceptsBothCargoGroupsOnlyAfterDeparture() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
|
||||
var queries=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
|
||||
var source=OwnedBusinessFixtures.source();when(repo.source(source.getDispatchNo())).thenReturn(source);
|
||||
assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
|
||||
for(boolean pooled:List.of(false,true))for(String stage:List.of("DELIVERING","DELIVERED")) {
|
||||
source.setPooledCargo(pooled);source.setStage(stage);
|
||||
assertSame(source,queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
|
||||
}
|
||||
assertThrows(BizException.class,()->queries.requireOwned(source.getDispatchNo()));
|
||||
source.setOwnedLoadingCompletionHash("other-load");
|
||||
assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
|
||||
verify(repo,never()).update(any(),anyLong());
|
||||
}
|
||||
/** 排车入口只接受油耗图片;签收和装车文件仍走派送单,旧外配仍不能上传油耗。 */
|
||||
@Test void scopesKeepIndependentWritePermissionsAndPhotoCategories() {
|
||||
var scope=OwnedBusinessScope.DISPATCH;
|
||||
assertEquals("platform:overseas-warehouse:dispatch:list",scope.listPermission());
|
||||
assertEquals("platform:overseas-warehouse:dispatch:edit",scope.uploadPermission());
|
||||
for(var slot:PhotoSlot.values()) {
|
||||
if(slot.category().equals("FUEL")) { assertDoesNotThrow(()->scope.requireSlot(slot));assertThrows(BizException.class,()->OwnedBusinessScope.OUTSOURCED.requireSlot(slot)); }
|
||||
else assertThrows(BizException.class,()->scope.requireSlot(slot));
|
||||
}
|
||||
var actor=new Actor("reader","只读",Set.of(scope.listPermission()));
|
||||
var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor,scope);
|
||||
assertFalse(row.actions().upload().allowed());
|
||||
}
|
||||
/** 油耗与车队装车照片共存,排车补充油耗不破坏外配业务读取。 */
|
||||
@Test void sharedFuelPhotosDoNotInvalidateOutsourcedProjection() {
|
||||
var codec=OwnedBusinessFixtures.codec();var record=new OwnedBusinessRows.Record();record.setRecordVersion(3L);record.setArrivalsJson("[]");
|
||||
record.setPhotosJson(codec.write(List.of(new Photo(PhotoSlot.FUEL_START,"FUEL","77","fuel.png","image/png","9","2026-10-21T00:00:00Z","2026-10-21T00:00:00Z"))));
|
||||
var projection=new OwnedBusinessProjection(codec);var source=OwnedBusinessFixtures.source();source.setPooledCargo(true);source.setStage("DELIVERED");
|
||||
var row=projection.row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor(),OwnedBusinessScope.OUTSOURCED);
|
||||
assertEquals(1,row.photoSummary().fuelCount());assertEquals("3",row.recordVersion());assertEquals(8L,source.getRecordVersion());assertEquals("DELIVERED",source.getStage());
|
||||
assertEquals("77",projection.photos(record).get(0).fileId());
|
||||
var queries=mock(OwnedBusinessQueryService.class);
|
||||
when(queries.detail(any(),eq(source.getDispatchNo()),eq(OwnedBusinessScope.OUTSOURCED)))
|
||||
.thenReturn(new Detail(row,null,List.of(),projection.photos(record)));
|
||||
var outsourced=new OutsourcedBusinessService(queries,mock(OwnedBusinessRecordService.class),mock(OwnedBusinessPhotoService.class),projection,codec);
|
||||
assertTrue(outsourced.detail(OwnedBusinessFixtures.actor(),source.getDispatchNo()).photos().isEmpty());
|
||||
}
|
||||
/** 新入口读写权限独立;服务层同样拒绝仅查看账号和车队权限绕入。 */
|
||||
@Test void controllerAndPhotoServiceKeepDispatchWriteBoundary() throws Exception {
|
||||
assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:list')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods())
|
||||
.filter(m->m.getName().equals("get")).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value());
|
||||
for(String name:List.of("upload","bind","recover"))assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:edit')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods())
|
||||
.filter(m->m.getName().equals(name)).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value());
|
||||
var queries=mock(OwnedBusinessQueryService.class);var repo=mock(OwnedBusinessRepository.class);var files=mock(OwnedBusinessFileGateway.class);var codec=OwnedBusinessFixtures.codec();
|
||||
var service=new OwnedBusinessPhotoService(queries,mock(OwnedBusinessRecordService.class),repo,mock(OwnedBusinessCommandService.class),new OwnedBusinessProjection(codec),codec,files);
|
||||
for(var actor:List.of(new Actor("read","只读",Set.of(OwnedBusinessScope.DISPATCH.listPermission())),OwnedBusinessFixtures.actor()))
|
||||
assertThrows(ResponseStatusException.class,()->service.upload(actor,"PC-1",PhotoSlot.FUEL_START,"upload-original",null,OwnedBusinessScope.DISPATCH));
|
||||
verifyNoInteractions(files,repo,queries);
|
||||
}
|
||||
}
|
||||
+8
@@ -60,4 +60,12 @@ class FleetPickupMapperTest {
|
||||
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)");
|
||||
assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");
|
||||
}
|
||||
/** 联动取消保留原车次身份,已取货成员不能被取消SQL覆盖。 */
|
||||
@Test void cancellationKeepsSourceLinkAndDoesNotRewritePickedFacts() {
|
||||
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','ASSIGNED',CURRENT_TIMESTAMP)");
|
||||
db.update("INSERT INTO ll_fleet_pickup_task VALUES(6,'C6','WT-6','TR-1','PICKED',CURRENT_TIMESTAMP)");
|
||||
assertThat(mapper.cancelTasks("TR-1")).isEqualTo(1);
|
||||
assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");assertThat(mapper.task(1L).getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(mapper.task(6L).getStatus()).isEqualTo("PICKED");assertThat(mapper.cancelTasks("TR-1")).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
+44
-4
@@ -15,11 +15,15 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class FleetPickupServiceTest {
|
||||
FleetPickupRepository repository;FleetTripService trips;FleetPickupService service;
|
||||
PickupDepartureReview departureReview;
|
||||
DriverService drivers;VehicleRepository vehicles;
|
||||
TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task;
|
||||
final Map<String,String> commands=new HashMap<>();
|
||||
@BeforeEach void setup() {
|
||||
repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class);
|
||||
service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),mock(DriverService.class),mock(VehicleRepository.class),files,new ObjectMapper().findAndRegisterModules());
|
||||
departureReview=mock(PickupDepartureReview.class);
|
||||
drivers=mock(DriverService.class);vehicles=mock(VehicleRepository.class);
|
||||
service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),drivers,vehicles,files,new ObjectMapper().findAndRegisterModules(),departureReview);
|
||||
trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L);
|
||||
source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED");
|
||||
when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip);
|
||||
@@ -29,6 +33,29 @@ class FleetPickupServiceTest {
|
||||
doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString());
|
||||
doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
|
||||
}
|
||||
/** 审核完成后直接在装货阶段上传取货照片,全车核齐自动进入派送。 */
|
||||
@Test void pickupPhotosStartDeliveryWithoutSeparateDeparture() {
|
||||
trip.setStatus("PLANNED");trip.setVehicleId(20L);trip.setDriverId(30L);trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).plusDays(1));
|
||||
when(repository.ownsResources(9L,20L,30L)).thenReturn(true);
|
||||
var vehicle=mock(VehicleRepository.VehicleSnapshot.class);when(vehicle.region()).thenReturn("OVERSEAS");when(vehicle.status()).thenReturn("IN_SERVICE");when(vehicles.lockById(20L)).thenReturn(Optional.of(vehicle));
|
||||
var driver=mock(DriverModels.Response.class);var details=mock(DriverModels.Draft.class);when(details.region()).thenReturn("OVERSEAS");when(driver.details()).thenReturn(details);when(drivers.requireDispatchable(eq(30L),any())).thenReturn(driver);
|
||||
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
|
||||
when(files.metadata(9L,88L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("88","取货照片.jpg","image/jpeg",500));
|
||||
var request=new FleetPickupModels.Pickup(2L,"pickup-photo-key",5,List.of(88L),null);
|
||||
service.picked("TR-1","C1","WT-1","OP",request);
|
||||
assertThat(task.getStatus()).isEqualTo("PICKED");assertThat(trip.getStatus()).isEqualTo("IN_TRANSIT");assertThat(trip.getDepartedAt()).isNotNull();
|
||||
verify(departureReview).requireApproved(9L);verify(trips,times(1)).recordExecution(any(),eq(2L),eq("PICKUP_PICKED"),anyString(),eq("OP"));
|
||||
service.picked("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task);
|
||||
}
|
||||
/** 原车队发车接口不能在两级审核未通过时写入实际发车事实。 */
|
||||
@Test void legacyDepartureCannotBypassReview() {
|
||||
trip.setStatus("PLANNED");
|
||||
doThrow(new IllegalStateException("请先完成两级审核")).when(departureReview).requireApproved(9L);
|
||||
assertThatThrownBy(()->service.depart("TR-1","OP",new FleetPickupModels.Command(2L,"depart-review-key","发车"))).hasMessageContaining("两级审核");
|
||||
assertThat(trip.getStatus()).isEqualTo("PLANNED");assertThat(trip.getDepartedAt()).isNull();
|
||||
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
|
||||
assertThat(commands).isEmpty();
|
||||
}
|
||||
/** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */
|
||||
@Test void pickupMustArriveBeforePda() {
|
||||
assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓");
|
||||
@@ -79,10 +106,23 @@ class FleetPickupServiceTest {
|
||||
}
|
||||
@Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() {
|
||||
task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台"));
|
||||
when(repository.ownsUpload(1L,"TR-1",89L)).thenReturn(true);
|
||||
when(files.metadata(9L,89L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("89","到仓照片.jpg","image/jpeg",500));
|
||||
var request=new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台",List.of(89L));
|
||||
service.arrive("TR-1","C1","WT-1","OP",request);
|
||||
assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
|
||||
service.complete("TR-1","OP",new FleetPickupModels.Command(3L,"complete-key-1","全部送仓"));
|
||||
assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("complete-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
|
||||
assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("arrive-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
|
||||
assertThat(task.getEvidenceJson()).contains("ARRIVAL","到仓照片");
|
||||
service.arrive("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task);
|
||||
}
|
||||
/** 到仓必须上传独立的新照片,不能用取货照片冒充或提前开放清点。 */
|
||||
@Test void arrivalRequiresFreshPhotoEvidence() {
|
||||
task.setStatus("PICKED");
|
||||
task.setEvidenceJson("[{\"fileId\":\"88\",\"fileName\":\"取货.jpg\",\"contentType\":\"image/jpeg\",\"sizeBytes\":1,\"phase\":\"PICKUP\"}]");
|
||||
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"no-photo","送仓"))).hasMessageContaining("上传到仓照片");
|
||||
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
|
||||
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"same-photo","送仓",List.of(88L)))).hasMessageContaining("不能复用取货照片");
|
||||
assertThat(task.getStatus()).isEqualTo("PICKED");verify(repository,never()).update(any());
|
||||
}
|
||||
@Test void staleVersionAndWrongTripPurposeAreRejected() {
|
||||
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化");
|
||||
|
||||
+2
@@ -122,6 +122,8 @@ class FleetTripAuthorizationTest {
|
||||
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); }
|
||||
/** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */
|
||||
@Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); }
|
||||
/** 取消原单使用所属域替身,权限测试不写业务数据。 */
|
||||
@Bean com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation() { return mock(com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation.class); }
|
||||
@Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); }
|
||||
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); }
|
||||
@Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); }
|
||||
|
||||
+2
-2
@@ -75,7 +75,6 @@ class PdaOperationsOpenApiContractTest {
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}:",
|
||||
@@ -83,7 +82,8 @@ class PdaOperationsOpenApiContractTest {
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
|
||||
for (String path : paths) assertTrue(yaml.contains(path), path);
|
||||
assertEquals(36, paths.size());
|
||||
assertEquals(35, paths.size());
|
||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
|
||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:"));
|
||||
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
|
||||
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.lastleg.transfer.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.fleet.pickup.FleetPickupModels;
|
||||
import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper;
|
||||
import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 通过真实SQL验证联动取消、客户隔离以及多票失败回滚。 */
|
||||
class TransferPickupCancellationPersistenceTest {
|
||||
JdbcTemplate db;TransactionTemplate tx;TransferPickupCancellationService service;
|
||||
/** 每例建立隔离库,保留取消所需真实字段和状态约束。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_cancel_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_id BIGINT,inbound_order_id BIGINT,delivery_method VARCHAR(40),status VARCHAR(32),updated_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),current_status VARCHAR(40),sub_order_status VARCHAR(255),version INT,updated_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_inbound_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),status VARCHAR(40),updated_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_order_id BIGINT,creation_mode VARCHAR(40),status VARCHAR(40),version INT,updated_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_loading_order_status_history(customer_id VARCHAR(64),order_id BIGINT,from_status VARCHAR(40),to_status VARCHAR(40),event_id VARCHAR(64),remark VARCHAR(1000),created_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_receipt(customer_id VARCHAR(64),transfer_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR(64),transfer_order_id BIGINT,counted_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(customer_id VARCHAR(64),transfer_order_id BIGINT)");
|
||||
for(int i=1;i<=2;i++) {
|
||||
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(?,? ,? ,? ,?,?,'WAREHOUSE_PICKUP','WAIT_PDA_RECEIVING',CURRENT_TIMESTAMP)",i,"C"+i,"WT"+i,"WT"+i,i,i);
|
||||
db.update("INSERT INTO ll_order VALUES(?,?,'INBOUND_MEASURING','WAREHOUSE_TRANSFER:APPROVED',0,CURRENT_TIMESTAMP)",i,"C"+i);
|
||||
db.update("INSERT INTO ll_inbound_order VALUES(?,?,'PENDING_INBOUND',CURRENT_TIMESTAMP)",i,"C"+i);
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(?, ?,?,'WAREHOUSE_TRANSFER','PDA_PENDING',0,CURRENT_TIMESTAMP)",i,"C"+i,i);
|
||||
}
|
||||
var config=new MybatisConfiguration();config.addMapper(TransferPickupCancellationMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
service=new TransferPickupCancellationService(new TransferPickupCancellationRepository(new SqlSessionTemplate(factory.getObject()).getMapper(TransferPickupCancellationMapper.class)));
|
||||
}
|
||||
/** 释放本例数据库资源。 */
|
||||
@AfterEach void close(){db.execute("SHUTDOWN");}
|
||||
/** 全车跨客户成员分别取消自己的原单与计划,不影响其他客户。 */
|
||||
@Test void cancelsOnlyTheLinkedBusinessAndKeepsAudit() {
|
||||
tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1")),"TR1","OP","客户取消"));
|
||||
for(String table:List.of("ll_warehouse_transfer_order","ll_inbound_order","ll_inbound_plan")) {
|
||||
assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=1",String.class)).isEqualTo("CANCELLED");
|
||||
assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=2",String.class)).isNotEqualTo("CANCELLED");
|
||||
}
|
||||
assertThat(db.queryForObject("SELECT current_status FROM ll_order WHERE id=1",String.class)).isEqualTo("CANCELLED");
|
||||
assertThat(db.queryForObject("SELECT version FROM ll_order WHERE id=1",Integer.class)).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT remark FROM ll_loading_order_status_history",String.class)).contains("OP","客户取消");
|
||||
}
|
||||
/** 后一票已经清点时,前一票取消及历史写入全部回滚。 */
|
||||
@Test void oneStartedMemberRollsBackAllOtherCancellations() {
|
||||
db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('C2',2,CURRENT_TIMESTAMP)");
|
||||
assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1"),new FleetPickupModels.Source("C2","WT2")),"TR1","OP","取消"))).hasMessageContaining("不能取消");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_order WHERE status='CANCELLED'",Integer.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_loading_order_status_history",Integer.class)).isZero();
|
||||
assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C2","WT1")),"TR1","OP","取消"))).hasMessageContaining("不能取消");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -27,7 +27,7 @@ class WarehouseTransferCartonCountTest {
|
||||
service = spy(new WarehouseTransferService());
|
||||
ReflectionTestUtils.setField(service, "transfers", orders);
|
||||
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
|
||||
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
|
||||
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class)));
|
||||
ReflectionTestUtils.setField(service, "transferCartons", cartons);
|
||||
ReflectionTestUtils.setField(service, "transferItems", items);
|
||||
ReflectionTestUtils.setField(service, "transferPallets", palletMapper);
|
||||
|
||||
+4
-1
@@ -202,6 +202,7 @@ class WarehouseTransferServiceTest {
|
||||
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
|
||||
private WarehouseTransferService service;
|
||||
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
|
||||
@Mock com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks;
|
||||
@Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
|
||||
|
||||
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
|
||||
@@ -209,6 +210,7 @@ class WarehouseTransferServiceTest {
|
||||
void setUp() {
|
||||
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
|
||||
service = new WarehouseTransferService();
|
||||
ReflectionTestUtils.setField(service, "transportTaskService", transportTasks);
|
||||
var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository();
|
||||
ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons);
|
||||
ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository);
|
||||
@@ -233,7 +235,7 @@ class WarehouseTransferServiceTest {
|
||||
});
|
||||
ReflectionTestUtils.setField(service, "transfers", transfers);
|
||||
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
|
||||
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
|
||||
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class)));
|
||||
ReflectionTestUtils.setField(service, "transferItems", transferItems);
|
||||
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
|
||||
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
|
||||
@@ -724,6 +726,7 @@ class WarehouseTransferServiceTest {
|
||||
assertEquals(5, line.getValue().getRequiredPackageCount());
|
||||
assertEquals("PENDING_INBOUND", inbound.getStatus());
|
||||
assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus());
|
||||
verify(transportTasks).ensureApprovedPickup("TENANT-A", "WTR-1", "USER-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -447,6 +447,8 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块
|
||||
|
||||
## 后端分层检查
|
||||
|
||||
Jenkins 总控的测试范围入口为 `../deploy/scripts/ci-test-scope.mjs`,由 `TEST_SCOPE=AUTO|MODULE` 控制,不部署、不访问业务数据库。`plan` 使用带门禁通过标记且覆盖目标服务的成功构建基线与当前源码生成 Job 临时目录的 JSON,`checks` 执行受影响的既有架构合同,`args` 只输出精确测试类参数,`verify` 拒绝缺失、陈旧、失败或全部跳过的所选类报告。AUTO 当前仅优化已识别的单 Lastleg 局部变更,公共/未知/配置/SQL/多服务等保留完整回归;完整编译保留,真实环境 opt-in 不计通过,JVM 内存与隔离配置不放宽。定向列表在默认 Surefire execution 执行,关闭后置 SQL execution 的重复执行;未指定定向参数时原完整 SQL 回归不变。静态筛选边界及 MODULE 使用条件见工作区 `docs/测试环境Jenkins部署指南.md`;直接合同为 `ci-test-scope.test.mjs`。本地普通任务继续使用下述显式范围入口,不自动触发 CI 或增加完整回归。
|
||||
|
||||
`backend-check.ps1` 是本地日常入口,PowerShell 7 执行,自动经 `local-check.ps1` 占用一个重型名额。全量触发条件唯一见 `../AGENTS.md`;不自动根据 Git diff 猜测跨服务消费者,不自动缓存或跳过测试。
|
||||
|
||||
在后端根目录执行,`-Modules` 使用逗号分隔的当前根 POM 模块名:
|
||||
|
||||
@@ -157,6 +157,10 @@ $adminIndex = Read-RequiredText $adminIndexPath
|
||||
$adminAgents = $adminEntry + "`n" + $adminIndex
|
||||
$adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md'
|
||||
$adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath
|
||||
# 页面细则归入既有业务正文;只读取对应子节,保留原有字段和状态断言。
|
||||
$adminInboundPageRules = [regex]::Match($businessRules, '(?ms)^### Admin 入仓计划页面约束\r?\n(?<body>.*?)(?=^#{1,3} |\z)').Groups['body'].Value
|
||||
$checked++
|
||||
if (-not $adminInboundPageRules) { Add-Failure '缺少 Admin 入仓计划页面约束正文' }
|
||||
Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口'
|
||||
Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表'
|
||||
$clientEntry = Read-RequiredText $clientAgentsPath
|
||||
@@ -245,7 +249,8 @@ $lookupSamples = @(
|
||||
$lookupCharacters = 0
|
||||
foreach ($alias in @(
|
||||
@{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' },
|
||||
@{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
|
||||
@{ Term = '转运通过'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
|
||||
@{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' },
|
||||
@{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' },
|
||||
@{ Term = '订单审核'; Entry = 'apps/admin-web/src/modules/unified-orders/' },
|
||||
@{ Term = 'PDA 出库'; Entry = 'apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue' },
|
||||
@@ -291,7 +296,8 @@ foreach ($document in @(
|
||||
@{ Name = '业务导航'; Path = $businessIndexPath; Text = $businessIndex; Limit = 12500 },
|
||||
@{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 },
|
||||
@{ Name = '海外仓文档入口'; Path = $overseasDocEntryPath; Text = $overseasDocEntry; Limit = 2000 },
|
||||
@{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 }
|
||||
@{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 },
|
||||
@{ Name = 'Admin 海外仓指引'; Path = $adminOverseasAgentsPath; Text = $adminOverseasAgents; Limit = 8000 }
|
||||
)) {
|
||||
Assert-MarkdownLinks $document.Path $document.Text $document.Name
|
||||
$checked++
|
||||
@@ -306,11 +312,21 @@ foreach ($document in @(
|
||||
)) { Assert-MarkdownLinks $document.Path $document.Text $document.Name }
|
||||
Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表'
|
||||
$businessRouteSection = (($businessIndex -split '(?m)^## 业务快速路由表\r?$', 2)[1] -split '(?m)^## ', 2)[0]
|
||||
foreach ($topic in [regex]::Matches($businessRules, '(?m)^## (?<name>.+?)\r?$')) {
|
||||
$prefix = '| ' + $topic.Groups['name'].Value + ' |'
|
||||
$hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) })
|
||||
# 同一规则可供多个终端入口复用;校验实际正文链接,不要求路由名称复制正文标题。
|
||||
$businessRuleSections = [regex]::Matches($businessRules, '(?ms)^## (?<name>[^\r\n]+)\r?\n(?<body>.*?)(?=^## |\z)')
|
||||
foreach ($topic in $businessRuleSections) {
|
||||
$name = $topic.Groups['name'].Value
|
||||
$slug = [regex]::Replace($name.Trim().ToLowerInvariant(), '[^\p{L}\p{M}\p{N}_\s-]', '') -replace '\s+', '-'
|
||||
$target = '业务实现边界.md#' + $slug
|
||||
$hasRoute = $businessRouteSection.Contains('](' + $target + ')')
|
||||
if (-not $hasRoute) {
|
||||
# 已有正式正文时允许索引直接跳转,避免为覆盖率强加一层摘要。
|
||||
$sourceLine = [regex]::Match($topic.Groups['body'].Value, '(?m)^范围依据:[^\r\n]+')
|
||||
$sourceLink = [regex]::Match($sourceLine.Value, '\[[^\]]+\]\((?<target>[^)]+)\)')
|
||||
$hasRoute = $sourceLink.Success -and $businessRouteSection.Contains('](' + $sourceLink.Groups['target'].Value + ')')
|
||||
}
|
||||
$checked++
|
||||
if ($hits.Count -ne 1) { Add-Failure "业务正文必须有唯一导航入口:$($topic.Groups['name'].Value)" }
|
||||
if (-not $hasRoute) { Add-Failure "业务正文或其正式依据缺少导航入口:$name" }
|
||||
}
|
||||
Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单'
|
||||
Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件'
|
||||
@@ -319,20 +335,68 @@ Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史
|
||||
Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文'
|
||||
Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文'
|
||||
Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文'
|
||||
# 入口存在不足以证明动作存在:核对弹框实际调用与 API 结论,避免将接口能力当成按钮。
|
||||
$transferReviewUi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue')
|
||||
$transferReviewApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts')
|
||||
$transferUiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 仓储转运审核、审核通过 |') })
|
||||
$transferApiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 转运驳回、REJECT 接口能力 |') })
|
||||
$checked++
|
||||
if ($transferUiRow.Count -ne 1 -or $transferApiRow.Count -ne 1) {
|
||||
Add-Failure '转运审核的界面入口与 API 能力必须分别定位'
|
||||
} else {
|
||||
Assert-Contains $transferUiRow[0] 'components/WarehouseTransferReviewDialog.vue' '转运通过界面入口'
|
||||
Assert-Contains $transferUiRow[0] 'APPROVE' '转运通过界面结论'
|
||||
Assert-Contains $transferApiRow[0] 'api/warehouseTransfers.ts' '转运驳回 API 入口'
|
||||
Assert-Contains $transferApiRow[0] 'REJECT' '转运 API 驳回结论'
|
||||
Assert-Matches $transferReviewUi 'reviewWarehouseTransfer\([^)]*,\s*[''"]APPROVE[''"](?:\s*,[^)]*)?\s*\)' '转运通过真实调用'
|
||||
Assert-Matches $transferReviewApi 'decision\s*:\s*[''"]APPROVE[''"]\s*\|\s*[''"]REJECT[''"]' '转运 API 允许的结论'
|
||||
if ($transferReviewUi -match 'reviewWarehouseTransfer\([^)]*,\s*[''"]REJECT[''"]') {
|
||||
Assert-NotContains $transferUiRow[0] '无驳回按钮' '转运界面能力变化后更新指引'
|
||||
Assert-Contains $transferUiRow[0] 'REJECT' '转运界面新增驳回后更新指引'
|
||||
} else {
|
||||
Assert-Contains $transferUiRow[0] '无驳回按钮' '转运界面不能虚构驳回动作'
|
||||
Assert-NotContains $transferUiRow[0] 'REJECT' '转运界面与 API 能力分开'
|
||||
}
|
||||
}
|
||||
Assert-Contains $adminOverseasAgents 'baiyue-firstleg/print/AGENTS.md' '托盘模板指向 Print 权威入口'
|
||||
Assert-NotContains $adminOverseasAgents 'INBOUND_MARK_V1' 'Admin 不复制箱唛模板为托盘模板'
|
||||
$adminOverseasVerification = [regex]::Match($adminOverseasAgents, '(?ms)^## 验证\r?\n(?<body>.*?)(?=^## |\z)').Groups['body'].Value
|
||||
$checked++
|
||||
if (-not $adminOverseasVerification -or ($adminOverseasVerification -replace '\r', '').Length -gt 800) {
|
||||
Add-Failure 'Admin 海外仓验证节须存在且不超过800字符,业务正文应归回按需条款'
|
||||
}
|
||||
|
||||
$docLookupCharacters = 0
|
||||
$walletRuleTarget = '财务管理/财务对账结算P1闭环.md#客户资金账户'
|
||||
$docLookupSamples = @(
|
||||
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
|
||||
@{ Key = '客户资金账户、充值、授信与业务扣费'; Text = $businessIndex; Entry = 'apps/client-web/AGENTS.md' },
|
||||
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
|
||||
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Entry = '财务对账结算P1闭环.md#客户资金账户' },
|
||||
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Entry = '排车列表统一作业改造契约.md' },
|
||||
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Entry = '业务规则.md#本土散板简化下单' }
|
||||
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
|
||||
@{ Key = '仓储转运审核、转运通过'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
|
||||
@{ Key = '转运驳回、REJECT'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' },
|
||||
@{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/warehouses/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
|
||||
@{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
|
||||
@{ Key = '海外库位、托盘与资源锁'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse-overseas-service/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
|
||||
@{ Key = '客户统计与运营指标'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/customers/AGENTS.md' },
|
||||
@{ Key = '平台日志、操作日志、审计日志与操作人缺失'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/audit/AGENTS.md' },
|
||||
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Column = 2; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
|
||||
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Column = 1; Entry = '财务对账结算P1闭环.md#客户资金账户' },
|
||||
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Column = 1; Entry = '排车列表统一作业改造契约.md' },
|
||||
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Column = 1; Entry = '业务规则.md#本土散板简化下单' }
|
||||
)
|
||||
foreach ($sample in $docLookupSamples) {
|
||||
$hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') })
|
||||
$checked++
|
||||
if ($hits.Count -ne 1) { Add-Failure "文档路由样本必须唯一命中:$($sample.Key)"; continue }
|
||||
Assert-Contains $hits[0] $sample.Entry "文档路由样本 $($sample.Key)"
|
||||
# 代码入口与规则正文分别核对,不能靠其他列碰巧包含目标路径通过。
|
||||
$cells = @($hits[0].Trim('|').Split('|') | ForEach-Object { $_.Trim() })
|
||||
if ($sample.Column -ge $cells.Count) { Add-Failure "文档路由样本缺少目标列:$($sample.Key)"; continue }
|
||||
Assert-Contains $cells[$sample.Column] $sample.Entry "文档路由样本 $($sample.Key)"
|
||||
if ($sample.Rules) {
|
||||
if ($cells.Count -lt 3) { Add-Failure "文档路由样本缺少规则正文:$($sample.Key)"; continue }
|
||||
Assert-Contains $cells[2] $sample.Rules "文档正文样本 $($sample.Key)"
|
||||
}
|
||||
$docLookupCharacters += $hits[0].Length
|
||||
}
|
||||
Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。"
|
||||
@@ -698,7 +762,7 @@ Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间
|
||||
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
|
||||
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口'
|
||||
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口'
|
||||
Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
|
||||
Assert-Contains $adminInboundPageRules '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
|
||||
Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界'
|
||||
Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界'
|
||||
Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁'
|
||||
@@ -1044,16 +1108,16 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans
|
||||
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
|
||||
Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
|
||||
Assert-Contains $adminInboundPageRules '/orders/container-summaries' 'Admin 柜级摘要目标入口'
|
||||
Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
|
||||
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
|
||||
Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
|
||||
Assert-Contains $adminInboundPageRules '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
|
||||
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
|
||||
Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
|
||||
Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
|
||||
Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
|
||||
Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
|
||||
Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
|
||||
Assert-Contains $adminInboundPageRules 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
|
||||
Assert-Contains $adminInboundPageRules '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
|
||||
Assert-Contains $adminInboundPageRules '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
|
||||
Assert-Contains $adminInboundPageRules '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
|
||||
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
|
||||
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
|
||||
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { planTests, verifyReports } from '../deploy/scripts/ci-test-scope.mjs'
|
||||
|
||||
const module = 'lastleg-service'
|
||||
function java(type, body = '', set = 'main') {
|
||||
const name = type.split('.').at(-1)
|
||||
const pkg = type.slice(0, -name.length - 1)
|
||||
return { path: `${module}/src/${set}/java/com/baiyue/lastleg/${type.replaceAll('.', '/')}.java`,
|
||||
source: `package com.baiyue.lastleg.${pkg}; class ${name} { ${body} }` }
|
||||
}
|
||||
const source = java('dispatch.DispatchService')
|
||||
const fixture = java('dispatch.Fixture', 'DispatchService service;', 'test')
|
||||
const direct = java('dispatch.DispatchTest', 'Fixture fixture; @Test void check() {}', 'test')
|
||||
const consumer = java('delivery.DeliveryService', 'DispatchService service;')
|
||||
const indirect = java('delivery.DeliveryTest', 'DeliveryService service; @Test void check() {}', 'test')
|
||||
const unrelated = java('selfoperated.AnalyticsTest', '@Test void check() {}', 'test')
|
||||
const security = java('workflow.OrderSecurityTest', '@Test void check() {}', 'test')
|
||||
const sources = [source, fixture, direct, consumer, indirect, unrelated, security]
|
||||
const plan = (options = {}) => planTests({ modules: [module], baseline: '1234567',
|
||||
changes: [source.path], sources, ...options })
|
||||
|
||||
test('changed Java selects transitive callers and fixture consumers without unrelated domains', () => {
|
||||
const result = plan()
|
||||
assert.equal(result.scope, 'AFFECTED')
|
||||
assert.deepEqual(result.tests, [
|
||||
'com.baiyue.lastleg.delivery.DeliveryTest', 'com.baiyue.lastleg.dispatch.DispatchTest',
|
||||
'com.baiyue.lastleg.workflow.OrderSecurityTest'
|
||||
])
|
||||
})
|
||||
|
||||
test('implementation changes reach callers of interfaces, including duplicate simple names', () => {
|
||||
const contract = java('dispatch.Port')
|
||||
const implementation = java('dispatch.Adapter', 'implements Port')
|
||||
const portCaller = java('fleet.PortCaller', 'Port port;')
|
||||
const portTest = java('fleet.PortCallerTest', 'PortCaller service; @Test void check() {}', 'test')
|
||||
const result = plan({ changes: [implementation.path], sources: [...sources, contract, implementation, portCaller, portTest] })
|
||||
assert.ok(result.tests.includes('com.baiyue.lastleg.fleet.PortCallerTest'))
|
||||
})
|
||||
|
||||
test('SQL tables expand to other mappers and their tests', () => {
|
||||
const mapper = java('dispatch.SourceMapper', 'String sql = "UPDATE ll_dispatch_source SET version=2";')
|
||||
const sibling = java('delivery.DeliveryMapper', 'String sql = "UPDATE ll_dispatch_source SET version=1";')
|
||||
const check = java('delivery.DeliveryMapperTest', 'DeliveryMapper mapper; @Test void check() {}', 'test')
|
||||
const result = plan({ changes: [mapper.path], sources: [...sources, mapper, sibling, check] })
|
||||
assert.ok(result.tests.includes('com.baiyue.lastleg.delivery.DeliveryMapperTest'))
|
||||
})
|
||||
|
||||
test('unchanged writes beside a changed SELECT do not pull in unrelated table consumers', () => {
|
||||
const mapper = java('dispatch.QueryMapper', '@Update("UPDATE ll_shared SET value=1") void write(); @Select("SELECT value FROM ll_source") void read();')
|
||||
mapper.previousSource = mapper.source.replace('SELECT value', 'SELECT old_value')
|
||||
const check = java('dispatch.QueryMapperTest', 'QueryMapper mapper; @Test void check() {}', 'test')
|
||||
const unrelatedTable = java('selfoperated.SharedTableTest', 'String query = "SELECT * FROM ll_shared"; @Test void check() {}', 'test')
|
||||
const result = plan({ changes: [mapper.path], sources: [...sources, mapper, check, unrelatedTable] })
|
||||
assert.ok(result.tests.includes('com.baiyue.lastleg.dispatch.QueryMapperTest'))
|
||||
assert.ok(!result.tests.includes('com.baiyue.lastleg.selfoperated.SharedTableTest'))
|
||||
mapper.source = mapper.source.replace('value=1', 'value=2')
|
||||
assert.ok(plan({ changes: [mapper.path], sources: [...sources, mapper, check, unrelatedTable] })
|
||||
.tests.includes('com.baiyue.lastleg.selfoperated.SharedTableTest'))
|
||||
})
|
||||
|
||||
test('environment opt-in classes are reported separately and do not count as passing tests', () => {
|
||||
const optional = java('dispatch.RealMysqlTest', 'DispatchService service; @Test void check() {}', 'test')
|
||||
optional.source = optional.source.replace('class RealMysqlTest', '@EnabledIfSystemProperty(named="live",matches="true") class RealMysqlTest')
|
||||
const result = plan({ sources: [...sources, optional] })
|
||||
assert.ok(!result.tests.includes('com.baiyue.lastleg.dispatch.RealMysqlTest'))
|
||||
assert.deepEqual(result.optInTests, ['com.baiyue.lastleg.dispatch.RealMysqlTest'])
|
||||
})
|
||||
|
||||
test('changed test fixtures and deleted Java types keep their dependent tests', () => {
|
||||
assert.ok(plan({ changes: [fixture.path] }).tests.includes('com.baiyue.lastleg.dispatch.DispatchTest'))
|
||||
const deleted = java('dispatch.RemovedService')
|
||||
const user = java('delivery.ReplacementTest', 'String old = "RemovedService"; @Test void check() {}', 'test')
|
||||
assert.ok(plan({ changes: [deleted.path], sources: [...sources, user] }).tests.includes('com.baiyue.lastleg.delivery.ReplacementTest'))
|
||||
})
|
||||
|
||||
test('public API changes also select the existing contract tests', () => {
|
||||
const contract = java('selfoperated.AnalyticsOpenApiContractTest', '@Test void check() {}', 'test')
|
||||
const result = plan({ changes: [source.path, `${module}/src/main/resources/openapi/dispatch.yaml`], sources: [...sources, contract] })
|
||||
assert.ok(result.tests.includes('com.baiyue.lastleg.selfoperated.AnalyticsOpenApiContractTest'))
|
||||
})
|
||||
|
||||
test('shared, unknown, configuration, schema and missing-baseline changes retain full regression', () => {
|
||||
for (const path of ['pom.xml', 'Jenkinsfile', 'common-core/src/main/java/Core.java',
|
||||
'deploy/scripts/run-ci-maven.sh', 'config/nacos/test/lastleg.yml',
|
||||
`${module}/pom.xml`, `${module}/src/main/resources/db/lastleg.sql`,
|
||||
`${module}/src/test/resources/test-config.yml`, `${module}/src/main/java/com/baiyue/lastleg/entity/Order.java`]) {
|
||||
assert.equal(plan({ changes: [source.path, path] }).scope, 'MODULE', path)
|
||||
}
|
||||
assert.equal(plan({ baseline: '' }).scope, 'MODULE')
|
||||
assert.equal(plan({ mode: 'MODULE' }).scope, 'MODULE')
|
||||
assert.equal(plan({ modules: ['order'] }).scope, 'MODULE')
|
||||
assert.equal(plan({ modules: [module, 'iam'] }).scope, 'MODULE')
|
||||
assert.equal(plan({ changes: [] }).scope, 'MODULE')
|
||||
assert.equal(plan({ sources: [source] }).scope, 'MODULE')
|
||||
assert.throws(() => plan({ mode: 'SKIP' }))
|
||||
})
|
||||
|
||||
test('architecture script changes run their own checks instead of broadening Java regression', () => {
|
||||
const result = plan({ changes: [source.path, 'scripts/agents-index-contract.ps1',
|
||||
`${module}/scripts/analyze-lastleg-dependencies.test.mjs`] })
|
||||
assert.equal(result.scope, 'AFFECTED')
|
||||
assert.deepEqual(result.staticChecks, [`${module}/scripts/analyze-lastleg-dependencies.test.mjs`])
|
||||
})
|
||||
|
||||
test('focused report verification rejects missing, failed and entirely skipped classes', () => {
|
||||
const focused = { scope: 'AFFECTED', tests: ['com.example.Test'] }
|
||||
const report = { name: 'com.example.Test', tests: 2, skipped: 0, failures: 0, errors: 0 }
|
||||
verifyReports(focused, [report])
|
||||
verifyReports(focused, [{ ...report, name: 'com.example.Test$Nested' }])
|
||||
for (const reports of [[], [{ ...report, tests: 0 }], [{ ...report, skipped: 2 }],
|
||||
[{ ...report, failures: 1 }], [{ ...report, errors: 1 }], [{ ...report, tests: NaN }]]) {
|
||||
assert.throws(() => verifyReports(focused, reports))
|
||||
}
|
||||
})
|
||||
|
||||
test('CLI prevents double execution of selected classes in the SQL semantic execution', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'ci-test-scope-'))
|
||||
try {
|
||||
const file = join(directory, 'plan.json')
|
||||
writeFileSync(file, JSON.stringify(plan()))
|
||||
const args = execFileSync(process.execPath,
|
||||
['deploy/scripts/ci-test-scope.mjs', 'args', '--plan', file], { encoding: 'utf8' })
|
||||
assert.match(args, /-Dtest=com\.baiyue/)
|
||||
assert.match(args, /-Dsurefire.failIfNoSpecifiedTests=false/)
|
||||
assert.match(args, /-Dci.sql-semantic.skip=true/)
|
||||
const pom = readFileSync(new URL('../lastleg-service/pom.xml', import.meta.url), 'utf8')
|
||||
assert.match(pom, /<ci.sql-semantic.skip>false<\/ci.sql-semantic.skip>/)
|
||||
const execution = pom.slice(pom.indexOf('<id>sql-semantic-tests</id>'))
|
||||
assert.match(execution, /<skip>\$\{ci.sql-semantic.skip\}<\/skip>/)
|
||||
assert.match(execution, /<reuseForks>false<\/reuseForks>/)
|
||||
} finally { rmSync(directory, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
test('Jenkins plans from a successful baseline, verifies fresh reports and preserves module override', () => {
|
||||
const pipeline = readFileSync(new URL('../Jenkinsfile', import.meta.url), 'utf8')
|
||||
assert.match(pipeline, /candidate.result == 'SUCCESS' && markerIndex >= 0/)
|
||||
assert.match(pipeline, /requiredServices.every \{ metadata\[1\].split\(','\).contains\(it\) \}/)
|
||||
assert.match(pipeline, /if \(env.TEST_GATE_PASSED == 'true'\)/)
|
||||
assert.match(pipeline, /TESTS=PASSED \$\{env.SOURCE_COMMIT\} \$\{env.SELECTED_SERVICES\}/)
|
||||
assert.match(pipeline, /summary.indexOf\(' \| '\)/)
|
||||
assert.match(pipeline, /env.TEST_BASE_COMMIT = previousSuccessfulTestCommit/)
|
||||
assert.match(pipeline, /choice\(name: 'TEST_SCOPE', choices: \['AUTO', 'MODULE'\]/)
|
||||
assert.match(pipeline, /ci-test-scope.mjs plan --modules/)
|
||||
assert.match(pipeline, /ci-test-scope.mjs checks --plan/)
|
||||
assert.match(pipeline, /-am test \\\$test_arguments/)
|
||||
assert.match(pipeline, /ci-test-scope.mjs verify --plan/)
|
||||
assert.match(pipeline, /booleanParam\(name: 'SKIP_TESTS', value: true\)/)
|
||||
})
|
||||
Reference in New Issue
Block a user