Compare commits

..
Author SHA1 Message Date
liuyihe bbf8f2f792 尾程订单列表查询修改 2026-10-21 21:49:48 +08:00
ouyangxq 5356a8c898 优化 2026-10-21 21:30:24 +08:00
ouyangxq 234216b3eb fix(dispatch): 在审核及拣货前核对预约与运输时间 2026-10-21 19:42:32 +08:00
ouyangxq e2ad7857b4 feat(dispatch): 从当前排车提供内部散板需求池 2026-10-21 18:50:56 +08:00
ouyangxq 632d35faaf docs(api): describe unknown actual loading measurements 2026-10-21 18:45:06 +08:00
ouyangxq 538a63c635 fix(dispatch): confirm scanned loading without repeated measurements 2026-10-21 18:44:26 +08:00
ouyangxq 6088ff10c2 perf(ci): scope Lastleg tests to affected changes 2026-10-21 18:28:15 +08:00
liuyihe 6d04659ced 提交 2026-10-21 18:00:13 +08:00
liuyihe 044812cd90 提交 2026-10-21 17:52:28 +08:00
liuyihe ae361879e5 Merge remote-tracking branch 'origin/test' into test 2026-10-21 17:27:17 +08:00
liuyihe c442c9eaa7 提交 2026-10-21 17:26:43 +08:00
ouyangxq 1ea7b4ff12 优化 2026-10-21 17:26:41 +08:00
ouyangxq 0976b6ef0d 优化 2026-10-21 17:22:19 +08:00
ouyangxq 0c6c55f6f1 优化 2026-10-21 17:08:52 +08:00
ouyangxq 4a0e86bd42 优化 2026-10-21 16:58:30 +08:00
133 changed files with 5162 additions and 716 deletions
Vendored
+33 -1
View File
@@ -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(', ')}")
}
+226
View File
@@ -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 }
}
+2
View File
@@ -52,6 +52,8 @@
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
## 唯一入口与区域边界
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
@@ -417,6 +417,7 @@ CREATE TABLE ll_dispatch_order (
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
+2 -1
View File
@@ -56,6 +56,7 @@
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;默认发布字段随原排车创建/编辑保存,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
@@ -77,7 +78,7 @@
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | 两端`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` |
+1 -1
View File
@@ -97,7 +97,7 @@
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
@@ -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 及其他消费者仍由门禁拒绝。
+5
View File
@@ -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.'));
@@ -25,6 +25,11 @@ public interface AppointmentWorkDispatchPort {
void requireDeparture(String dispatchNo);
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
String departureBlockReason(String dispatchNo);
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
/** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
List<ReceivingWindow> reviewEvidence(String dispatchNo);
void removeItem(long dispatchId,long itemId);
void requireTripBinding(String dispatchNo,long tripId);
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@FeignClient(name="order-service", contextId="lastlegOrderPreDepartureClient", path="/internal/order/v1/predeparture-orders")
public interface OrderPreDepartureClient {
@GetMapping
ApiResponse<PageResult<Row>> page(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam(required=false) Long customerId, @RequestParam(required=false) String orderNo,
@RequestParam(required=false) String inboundCode, @RequestParam int page, @RequestParam int size);
record Row(Long orderId, Long customerId, String orderNo, String inboundCode, String mainStatus,
String customerName, String lxCode, String receivingWarehouseName, String destinationWarehouseName,
String destinationCode, Integer packageCount, BigDecimal volumeCbm, BigDecimal grossWeightKg,
BigDecimal netWeightKg, LocalDateTime expectedArrivalAt, String containerNo, String cabinetNo,
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, LocalDateTime createdAt, LocalDateTime updatedAt) {}
}
@@ -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());
}
}
@@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets;
public class DispatchQueryController {
@Resource private DispatchAggregateService aggregateService;
@Resource private DispatchBolPrintService printService;
@Resource private com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@@ -47,6 +48,14 @@ public class DispatchQueryController {
return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query));
}
/** 内部需求池沿独立只读权限,不授予销售整车客户及司机资料读取权限。 */
@GetMapping("/capacity-demands")
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<PageResult<com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Demand>> capacityDemands(
@AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) {
requirePlatform(user); return ApiResponse.success(capacityDemands.page(query));
}
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@Operation(summary = "Get dispatch details and all pallet destinations")
@@ -9,6 +9,8 @@ public class DispatchAggregate {
private Long id;
private String dispatchNo;
private String taskType;
/** 是否向内部散板需求池发布本车余位。 */
private Boolean publishLooseboardDemand = true;
private Long tripId;
private String overseasWarehouseId;
private String overseasWarehouseCode;
@@ -40,12 +40,12 @@ public interface DispatchAggregateMapper {
/** 首次创建整车所有权及初始版本,不写customer_id。 */
@Insert("""
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code,
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,publish_looseboard_demand,trip_id,overseas_warehouse_id,overseas_warehouse_code,
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at,
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
remark,created_by,created_at,updated_at)
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.publishLooseboardDemand},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
@@ -66,6 +66,17 @@ public interface DispatchAggregateMapper {
+ " ORDER BY d.created_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<DispatchAggregate> page(@Param("q") Search query);
/** 只读余位来源仅含勾选发布、未发车的有效派送排车,不混入取货和本土卡车。 */
@Select("""
<script>SELECT d.* FROM ll_dispatch_order d
WHERE d.publish_looseboard_demand=TRUE AND d.task_type='DELIVERY' AND d.status='ACTIVE'
AND d.departed_at IS NULL AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
<if test='q.origin != null'>AND d.overseas_warehouse_name LIKE CONCAT('%',#{q.origin},'%')</if>
<if test='q.destination != null'>AND d.route LIKE CONCAT('%',#{q.destination},'%')</if>
ORDER BY d.planned_departure_at,d.created_at DESC,d.id DESC</script>
""")
List<DispatchAggregate> capacityDemands(@Param("q") com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query);
/** 分页总数复用完全相同筛选,不能用当前页长度替代。 */
@Select("<script>SELECT COUNT(*) FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER + "</script>")
long count(@Param("q") Search query);
@@ -91,14 +102,14 @@ public interface DispatchAggregateMapper {
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
@Update("""
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},publish_looseboard_demand=#{r.publishLooseboardDemand},
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount},
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
WHERE id=#{r.id} AND record_version=#{version}
""")
@@ -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);
@@ -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));
@@ -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());
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.port;
import java.math.BigDecimal;
import java.time.Instant;
/** 内部余位池仅公开本车运输摘要,不包含客户货物或司机私人资料。 */
public final class DispatchCapacityDemands {
/** 只承载只读查询模型。 */
private DispatchCapacityDemands() { }
public record Query(Integer page, Integer size, String origin, String destination) {
/** 查询范围有界,默认每页二十条。 */
public Query {
page = page == null ? 1 : page; size = size == null ? 20 : size;
if (page < 1 || size < 1 || size > 100) throw new IllegalArgumentException("余位需求分页参数无效");
origin = origin == null || origin.isBlank() ? null : origin.trim();
destination = destination == null || destination.isBlank() ? null : destination.trim();
}
}
public record Demand(String dispatchNo, String warehouseName, String route, Instant plannedDepartureAt,
Integer vehiclePalletSpaces, Integer scheduledPalletCount, Integer remainingPalletSpaces,
BigDecimal remainingWeightKg, BigDecimal remainingVolumeCbm, boolean capacityKnown, String capacityReason) { }
}
@@ -23,13 +23,16 @@ public final class DispatchCommands {
/** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations) { }
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
/** 新建排车默认发布内部余位,显式取消时保持不发布。 */
public Create { publishLooseboardDemand = publishLooseboardDemand == null ? true : publishLooseboardDemand; }
}
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt,
String remark) {
String remark, Boolean publishLooseboardDemand) {
/** 缺版本与有效版本零必须区分。 */
public Update { recordVersion = requireVersion(recordVersion); }
public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); }
}
public record AddCargo(Long recordVersion, String idempotencyKey, List<CargoSelection> cargo,
@@ -66,9 +69,9 @@ public final class DispatchCommands {
public Pick { recordVersion = requireVersion(recordVersion); }
}
/** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */
/** 按服务端已核平的扫码托盘确认装车,不要求现场重复测量或补填箱数。 */
public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash,
List<LoadingMeasurement> measurements, String remark) {
String remark) {
/** 后台与PDA共用必填版本约束。 */
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
}
@@ -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,
@@ -64,11 +64,12 @@ public final class DispatchViews {
ReviewState reviewState, Reviews reviews, Counts counts,
boolean pickingReconciled, Map<Action, Gate> actions,
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
List<OrderDetail> orderDetails) { }
List<OrderDetail> orderDetails, boolean publishLooseboardDemand) { }
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts,
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions) { }
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions,
Instant plannedDepartureAt, Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments) { }
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
Destination destination, long scheduledPackages, long pickedPackages,
@@ -34,6 +34,8 @@ public class DispatchAggregateRepository {
public DispatchAggregate lock(String number) { return mapper.lock(number); }
/** 整车分页。 */
public List<DispatchAggregate> page(DispatchAggregateMapper.Search query) { return mapper.page(query); }
/** 一次读取当前有效的内部余位来源,服务计算满载筛选后再分页。 */
public List<DispatchAggregate> capacityDemands(com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) { return mapper.capacityDemands(query); }
/** 整车分页总数。 */
public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); }
/** 全搜索基集的阶段和审核分布。 */
@@ -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); }
/** 真实库存数量及版本。 */
@@ -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();
}
@@ -302,9 +305,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setId(id); row.setDispatchNo(number); row.setTripId(shell.tripId());
row.setOverseasWarehouseId(shell.warehouseId()); row.setOverseasWarehouseCode(shell.warehouseCode()); row.setOverseasWarehouseName(shell.warehouseName());
row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L);
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L);
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
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,9 +388,13 @@ 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());
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
return saveCommand(row,actor,identity,request.remark());
@@ -398,7 +408,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 +425,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();
@@ -424,8 +434,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name();
if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化");
if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空");
if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
if(loading) {
if(request.decision()==Decision.APPROVED && !Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
if(loading && request.decision()==Decision.APPROVED) {
var dispatch=reviews(row).dispatchReview();
if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效");
}
@@ -527,10 +537,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true));
if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true));
}
var absent=new ArrayList<String>();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
var absent=new ArrayList<String>();if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList());
pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(),
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),1,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
}
if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源");
String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets));
@@ -540,7 +550,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);
@@ -552,17 +562,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
lockExecutionSources(row,items);
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
String appointmentBlock=appointments.departureBlockReason(number);
if(appointmentBlock!=null)throw conflict(appointmentBlock);
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
var supplied=new HashMap<Long,LoadingMeasurement>();
for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复");
for(var p:preview.pallets()) {
var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex()
|| p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对");
}
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
// 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。
var measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,
p.weightKg(),p.volumeCbm(),p.deliveryIndex())).sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
verifyReviewResources(row);var context=context(actor,row);
var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList();
var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
@@ -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()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
return hashes.source(facts);
}
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
private void invalidate(DispatchAggregate row,String reason) {
@@ -652,6 +668,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
void verifyReviewResources(DispatchAggregate row) {
if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;}
requireReviewAppointment(row);
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
@@ -659,9 +677,21 @@ 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("车辆或司机主档已变化,请重新保存并提交审核");
}
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
void requireReviewAppointment(DispatchAggregate row) {
String reason=reviewAppointmentBlock(row);
if(reason!=null)throw conflict(reason);
}
private String reviewAppointmentBlock(DispatchAggregate row) {
String reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
if(reason==null && row.getReviewBasisHash()!=null && !canSubmitReview(row.getReviewState()) && !Objects.equals(row.getReviewBasisHash(),basis(row)))
return "预约或运输安排已变化,原审核依据失效,请重新核对审核";
return reason;
}
/** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */
private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) {
@@ -720,6 +750,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
statistics(repository.summaries(filter)),Instant.now());
}
/** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */
List<DispatchViews.FbaAppointment> reviewAppointments(DispatchAggregate row) {
String number=row.getDispatchNo();var batches=repository.batches(row.getId());
var confirmedAppointments=this.appointments.reviewEvidence(number);
return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null);
}).toList();
}
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.Detail detail(Actor actor, String number) {
@@ -730,9 +772,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b));
var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(),
group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList();
var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream()
.map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList();
var appointments=reviewAppointments(row);
var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(),
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
d.status(),d.packageCount(),d.palletCount())).toList();
@@ -740,7 +780,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
documents,orderDetails(cargo));
documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand()));
}
/** 恢复动作结果前重新核验当前单据范围和原动作权限。 */
@@ -848,12 +888,24 @@ 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());
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
String reviewBlock=active && (stage==Stage.BEFORE_LOADING || stage==Stage.LOADING)
?reviewAppointmentBlock(row):null;
for(var action:Action.values()) {
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
boolean state=switch(action) {
@@ -871,6 +923,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
};
boolean permitted=hasPermission(actor,action);
gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件"));
if(state && permitted && reviewBlock!=null && Set.of(Action.SUBMIT_REVIEW,Action.DISPATCH_REVIEW,Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE).contains(action)) {
boolean rejectionAllowed=action==Action.DISPATCH_REVIEW || action==Action.LOADING_REVIEW;
gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock));
}
if(action==Action.LOADING_COMPLETE && state && permitted) {
String reason=appointments.departureBlockReason(row.getDispatchNo());
if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason));
}
}
return Collections.unmodifiableMap(gates);
}
@@ -940,12 +1000,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)));
}
/** 主档选择不接受请求覆盖其车牌和容量。 */
@@ -17,6 +17,12 @@ public class DispatchAppointmentAllocations {
private final AppointmentWorkDispatchPort provider;
public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;}
/** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */
public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);}
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList();
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
import java.math.BigDecimal;
import java.time.ZoneOffset;
import java.util.Objects;
/** 从日常排车实时投影内部余位,不创建第二套车次、发布单或占位。 */
@Service
public class DispatchCapacityDemandService {
private final DispatchAggregateRepository repository;
private final ObjectMapper json;
/** 复用排车仓储和正式装载快照解析。 */
public DispatchCapacityDemandService(DispatchAggregateRepository repository, ObjectMapper json) {
this.repository = repository; this.json = json;
}
/** 当前候选一次查询;已知满载先过滤,随后分页,保证总数与结果一致。 */
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public PageResult<DispatchCapacityDemands.Demand> page(DispatchCapacityDemands.Query query) {
var values = repository.capacityDemands(query).stream().map(this::project).filter(Objects::nonNull).toList();
long start = Math.min((long)(query.page()-1)*query.size(), values.size());
int end = (int)Math.min(start+query.size(), values.size());
return new PageResult<>(values.subList((int)start,end), query.page(), query.size(), values.size(), (values.size()+query.size()-1)/query.size());
}
/** 实托数不等于标准托位;缺少可靠装载证据时明确未知,不能拿三十托或零值推算。 */
private DispatchCapacityDemands.Demand project(DispatchAggregate row) {
Integer slots = null, remaining = null; BigDecimal weight = null, volume = null;
String reason = "占用标准托位、重量及体积待确认";
try {
var resources = row.getResourcesJson() == null ? null : json.readValue(row.getResourcesJson(), DispatchContracts.Resources.class);
var cap = resources == null || resources.vehicle() == null ? null : resources.vehicle().capacity();
if (cap != null) slots = cap.palletSpaces();
var load = row.getLoadingConfirmationJson() == null ? null : json.readValue(row.getLoadingConfirmationJson(), DispatchContracts.ConfirmedLoad.class);
if (cap == null || slots == null || slots <= 0 || !positive(cap.maxLoadKg()) || !positive(cap.maxVolumeCbm())) reason = "车辆真实容量待确认";
if (cap != null && load != null && load.pallets() != null && !load.pallets().isEmpty()
&& load.pallets().stream().allMatch(p -> p.warehousePalletId()!=null && p.palletSpaces()>0)
&& load.pallets().stream().map(DispatchContracts.LoadingMeasurement::warehousePalletId).distinct().count()==load.pallets().size()) {
// 首版均在起运仓上车,后续仅卸货;任一已知维度满载都退出需求池。
long occupied = load.pallets().stream().mapToLong(DispatchContracts.LoadingMeasurement::palletSpaces).sum();
if (slots != null && slots > 0) { if (occupied >= slots) return null; remaining = Math.toIntExact(slots-occupied); }
if (positive(cap.maxLoadKg()) && load.pallets().stream().allMatch(p -> positive(p.weightKg()))) {
var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::weightKg).reduce(BigDecimal.ZERO,BigDecimal::add);
if (used.compareTo(cap.maxLoadKg())>=0) return null; weight=cap.maxLoadKg().subtract(used);
}
if (positive(cap.maxVolumeCbm()) && load.pallets().stream().allMatch(p -> positive(p.volumeCbm()))) {
var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::volumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add);
if (used.compareTo(cap.maxVolumeCbm())>=0) return null; volume=cap.maxVolumeCbm().subtract(used);
}
if (remaining!=null && weight!=null && volume!=null) reason=null;
}
} catch (JsonProcessingException invalid) { reason="车辆或装载资料待核对"; }
return new DispatchCapacityDemands.Demand(row.getDispatchNo(),row.getOverseasWarehouseName(),row.getRoute(),
row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(ZoneOffset.UTC),slots,row.getPalletCount(),remaining,weight,volume,reason==null,reason);
}
/** 三维容量均须有真实正值。 */
private static boolean positive(BigDecimal value) { return value!=null && value.signum()>0; }
}
@@ -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) + "}";
@@ -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();
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.remark()));
var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false);
commands.append(context.dispatchId(),actor,identity,result);
return result;
}
/** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */
@Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public CommandResult departAfterDocuments(Actor actor,String number,String key) {
var context=workflow.lockForArrangement(actor,number);
if(context.stage()!=Stage.AFTER_LOADING || context.status()!=Status.ACTIVE)
throw new BizException(ResultCode.CONFLICT,"当前阶段不能按装车资料推进派送");
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
delivery.departureFacts(context,true);
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");
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;
}
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
@@ -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"));
}
}
@@ -87,44 +87,21 @@ 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)));
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions),
root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),
root.getEstimatedArrivalAt()==null?null:root.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC),workflow.reviewAppointments(root)));
}
return List.copyOf(result);
}
/** 预排首次扫码前只解析原计划实收仓位,不在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作业");}
@@ -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;
@@ -46,6 +47,7 @@ public class DispatchPickingService {
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING
|| context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码");
workflow.requireReviewAppointment(row);
if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法");
try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");}
var currentItems=repository.cargo(row.getId());
@@ -101,33 +103,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());}
@@ -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());
}
}
@@ -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);}
}
@@ -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));}
@@ -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) { }
}
@@ -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);}}
@@ -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) {
@@ -12,14 +12,14 @@ public final class TripCapacityPolicy {
public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) {
public Load {
if (palletSpaces < 0 || weightKg == null || volumeCbm == null
|| weightKg.signum() < 0 || volumeCbm.signum() < 0) {
if (palletSpaces < 0 || weightKg != null && weightKg.signum() < 0 || volumeCbm != null && volumeCbm.signum() < 0) {
throw new IllegalArgumentException("托位、重量和体积须为非负数");
}
}
public Load plus(Load other) {
return new Load(Math.addExact(palletSpaces, other.palletSpaces),
weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm));
weightKg == null || other.weightKg == null ? null : weightKg.add(other.weightKg),
volumeCbm == null || other.volumeCbm == null ? null : volumeCbm.add(other.volumeCbm));
}
}
@@ -28,7 +28,7 @@ public final class TripCapacityPolicy {
if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号");
if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后");
Objects.requireNonNull(load, "load");
if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) {
if (load.palletSpaces() == 0 || load.weightKg() != null && load.weightKg().signum() == 0 || load.volumeCbm() != null && load.volumeCbm().signum() == 0) {
throw new IllegalArgumentException("承运货物须有有效托位、重量和体积");
}
}
@@ -37,31 +37,48 @@ public final class TripCapacityPolicy {
public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { }
public static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings) {
return calculate(stopCount, capacity, bookings, false);
}
/** 装车使用已有测量;未知维度保留空值,仅校验已知货量,不把未知当作可售余量。 */
public static List<Segment> calculateActual(int stopCount, Load capacity, List<Booking> bookings) {
return calculate(stopCount, capacity, bookings, true);
}
private static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings, boolean actual) {
Objects.requireNonNull(capacity, "capacity");
Objects.requireNonNull(bookings, "bookings");
if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点");
if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
if (capacity.weightKg() == null || capacity.volumeCbm() == null || capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
throw new IllegalArgumentException("车辆容量必须大于零");
}
var numbers = new HashSet<String>();
for (Booking booking : bookings) {
Objects.requireNonNull(booking, "booking");
if (!actual && (booking.load().weightKg() == null || booking.load().volumeCbm() == null))
throw new IllegalArgumentException("计划承运货物须有重量和体积");
if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配");
if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线");
}
List<Segment> segments = new ArrayList<>();
for (int index = 0; index < stopCount - 1; index++) {
Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO);
BigDecimal knownWeight = BigDecimal.ZERO, knownVolume = BigDecimal.ZERO;
for (Booking booking : bookings) {
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load());
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) {
occupied = occupied.plus(booking.load());
if (booking.load().weightKg() != null) knownWeight = knownWeight.add(booking.load().weightKg());
if (booking.load().volumeCbm() != null) knownVolume = knownVolume.add(booking.load().volumeCbm());
}
}
if (occupied.palletSpaces() > capacity.palletSpaces()
|| occupied.weightKg().compareTo(capacity.weightKg()) > 0
|| occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) {
|| knownWeight.compareTo(capacity.weightKg()) > 0
|| knownVolume.compareTo(capacity.volumeCbm()) > 0) {
throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制");
}
Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(),
capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm()));
occupied.weightKg() == null ? null : capacity.weightKg().subtract(occupied.weightKg()),
occupied.volumeCbm() == null ? null : capacity.volumeCbm().subtract(occupied.volumeCbm()));
segments.add(new Segment(index, index + 1, occupied, remaining));
}
return List.copyOf(segments);
@@ -112,7 +112,7 @@ public final class FleetHandoverPolicy {
return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey());
}
private static boolean positive(Load load) {
return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
return load != null && load.palletSpaces() > 0 && load.weightKg() != null && load.volumeCbm() != null && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
}
private static boolean blank(String text) { return text == null || text.isBlank(); }
private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); }
@@ -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);
}
@@ -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,
@@ -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) { }
}
@@ -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); }
/** 核对旧拼车承运关系,防止同票走两条运输链。 */
@@ -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);
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.pickup;
/** 取货发车必须通过排车主单的本轮两级审核。 */
public interface PickupDepartureReview {
/** 调用方已持车次锁,校验并锁定对应排车审核事实。 */
void requireApproved(long tripId);
}
@@ -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);
}
@@ -31,7 +31,11 @@ public class FleetBookingReservations {
/** 使用当前自有货计划与全部未绑定预留计算区段,不重复计入待替换承运。 */
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
Long excludedId, FleetBookingEntity addition) {
return segments(trip,own.pallets(),excludedId,addition);
return segments(trip,own.pallets(),excludedId,addition,false);
}
/** 已扫码装车无需重复测量,未知重量体积不计作可接运力。 */
public List<TripCapacityPolicy.Segment> actualSegments(FleetTripEntity trip,List<FleetExecutionModels.Measurement> pallets) {
return segments(trip,pallets,null,null,true);
}
/** 已实收承运解除唯一排车后,自有货为零仍可按本票新实测和其他预留重新核容。 */
public void requireReceiptCapacity(FleetTripEntity trip,FleetExecutionModels.Planning own,FleetBookingEntity row) {
@@ -40,17 +44,20 @@ public class FleetBookingReservations {
throw conflict("请先重新核对自有货容量计划,再确认外接实收");
// 空自有货只用于已有实收的重新确认;不创建计划、不允许首次实收跳过自有货计划。
List<FleetExecutionModels.Measurement> pallets=releasedReceipt?List.of():own.capacityPlan().pallets();
segments(trip,pallets,row.getId(),row);
segments(trip,pallets,row.getId(),row,false);
}
/** 所有承诺使用同一容量计算,空自有货也必须保留其他承运占位及车辆上限。 */
private List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip,List<FleetExecutionModels.Measurement> own,
Long excludedId,FleetBookingEntity addition) {
Long excludedId,FleetBookingEntity addition,boolean actual) {
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
for (var pallet:own) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(),
new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm())));
for(var row:new FleetBookingCargoRepository(bookings).listForTrip(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row);
if(addition!=null) add(loads,stops,addition);
try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); }
try {
var capacity=new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm());
return actual?TripCapacityPolicy.calculateActual(stops.size(),capacity,loads):TripCapacityPolicy.calculate(stops.size(),capacity,loads);
}
catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());}
}
public void requireReassignmentFits(FleetTripEntity trip, Integer spaces, java.math.BigDecimal weight, java.math.BigDecimal volume) {
@@ -88,9 +88,8 @@ public class FleetTripExecutionService {
List<FleetExecutionModels.Measurement> measurements,String reason) {
var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true);
if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车已开始运输");
var ownSegments=capacity(trip,cargo,measurements);
var plan=new FleetExecutionModels.PlannedLoad(manifestHash,cargo,measurements,ownSegments,operator,Instant.now());
var segments=bookingReservations.segments(trip,plan,null,null);
capacity(trip,cargo,measurements,true);
var segments=bookingReservations.actualSegments(trip,measurements);
// 同时核对原有外接预订占用;不因新主流程绕过已确认容量。
var load=new FleetExecutionModels.ConfirmedLoad(manifestHash,cargo,measurements,segments,operator,Instant.now());
trip.setLoadJson(write(load));history.recordExecution(trip,trip.getRecordVersion(),"DISPATCH_CONFIRM_LOAD",reason,operator);
@@ -103,7 +102,7 @@ public class FleetTripExecutionService {
var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对");
if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId());
if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId());
bookingReservations.segments(trip,new FleetExecutionModels.PlannedLoad(load.manifestHash(),load.cargo(),load.pallets(),load.segments(),load.operatorId(),load.confirmedAt()),null,null);
bookingReservations.actualSegments(trip,load.pallets());
appointmentAllocations.requireDeparture(no);
}
@@ -157,7 +156,7 @@ public class FleetTripExecutionService {
String fingerprint = hash(write(cargo));
if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对计划容量");
var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList();
var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets), operator, Instant.now());
var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets, false), operator, Instant.now());
bookingReservations.segments(trip, plan, null, null);
trip.setPlannedLoadJson(write(plan));
history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator);
@@ -234,7 +233,7 @@ public class FleetTripExecutionService {
return null;
}
private List<TripCapacityPolicy.Segment> capacity(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo,
List<FleetExecutionModels.Measurement> pallets) {
List<FleetExecutionModels.Measurement> pallets, boolean actual) {
List<FleetTripModels.Stop> stops;
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
@@ -262,7 +261,8 @@ public class FleetTripExecutionService {
if (!measured.equals(destinations.keySet())) throw conflict("请确认每个实物托盘的托位、实际重量及体积");
if (trip.getMaxPalletSpaces() == null || trip.getMaxLoadKg() == null || trip.getMaxVolumeCbm() == null)
throw conflict("车辆容量未确认");
return TripCapacityPolicy.calculate(stops.size(), new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()), bookings);
var limits = new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm());
return actual ? TripCapacityPolicy.calculateActual(stops.size(), limits, bookings) : TripCapacityPolicy.calculate(stops.size(), limits, bookings);
} catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); }
}
private void requireResource(String kind, long id, long tripId) {
@@ -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;
@@ -425,8 +426,8 @@ public class FleetTripService {
if (trip.getLoadJson() != null) {
var load = read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class);
if (load.segments().stream().anyMatch(segment -> segment.occupied().palletSpaces() > vehicle.maxPalletSpaces()
|| segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0
|| segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0))
|| segment.occupied().weightKg() != null && segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0
|| segment.occupied().volumeCbm() != null && segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0))
throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排");
}
if (!Objects.equals(trip.getVehicleId(), vehicle.id()))
@@ -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;
}
/** 复核创建请求摘要,同键不同内容不允许复用。 */
@@ -141,15 +141,61 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
if(remaining.values().stream().anyMatch(n->n!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致");
if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致");
}
public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true); }
public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true,true); }
public String departureBlockReason(String dispatchNo) {
try { checkDeparture(dispatchNo,false);return null; }
try { checkDeparture(dispatchNo,false,true);return null; }
catch(org.springframework.web.server.ResponseStatusException error) {
if(error.getStatusCode().value()!=409) throw error;
return error.getReason();
}
}
private void checkDeparture(String dispatchNo,boolean locked) {
/** 审核使用计划/实收分配,不要求尚未发生的拣货;发车继续核对真实已拣来源。 */
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival) {
try {
checkDeparture(dispatchNo,false,false);
var windows=reviewEvidence(dispatchNo);
if(!windows.isEmpty()) {
if(departure==null || arrival==null)return "请先明确计划来车/发车时间和预计送达时间,再提交审核";
if(arrival.isBefore(departure))return "预计送达时间不能早于计划发车时间";
var first=windows.stream().min(Comparator.comparing(ReceivingWindow::startAt)).orElseThrow();
if(!departure.isBefore(first.startAt()))return "计划发车时间须早于本车最早的FBA预约送达时间";
if(arrival.isAfter(first.endAt()==null?first.startAt():first.endAt()))return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对";
}
return null;
} catch(org.springframework.web.server.ResponseStatusException error) {
if(error.getStatusCode().value()!=409)throw error;
return error.getReason();
}
}
/** 审核依据取当前有效收货确认,不能用旧批次头的计划时间替代。 */
public List<ReceivingWindow> reviewEvidence(String dispatchNo) {
var result=new ArrayList<ReceivingWindow>();var seen=new HashSet<List<String>>();
for(var row:mapper.bindings(dispatchNo)) {
if(row.batchKey().startsWith("DISPATCH:") || !seen.add(List.of(row.batchKey(),row.customerId(),row.destinationKey())))continue;
var agreement=receiving.currentAgreement(row.batchKey(),row.customerId());
if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus()) || agreement.details().appointmentAt()==null)continue;
var member=agreement.members().stream().filter(m->row.batchKey().equals(m.batchKey()) && row.customerId().equals(m.customerId())).findFirst().orElse(null);
if(member==null)continue;
var details=agreement.details();
result.add(new ReceivingWindow(row.batchKey(),member.appointmentNo(),row.customerId(),row.destinationKey(),details.confirmationNo(),
receivingInstant(details.appointmentAt(),agreement.timezone(),details.startOffset()),
details.appointmentEndAt()==null?null:receivingInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset()),agreement.agreementKey(),agreement.version()));
}
return result.stream().sorted(Comparator.comparing(ReceivingWindow::batchKey).thenComparing(ReceivingWindow::customerId).thenComparing(ReceivingWindow::destinationKey)).toList();
}
/** 保留仓库当地时区及明确偏移,不把美国预约时间按服务器时区解释。 */
private java.time.Instant receivingInstant(java.time.LocalDateTime at,String timezone,String offset) {
try {
if(timezone==null)throw conflict("预约收货时间的时区尚未明确");
var zone=java.time.ZoneId.of(timezone);
var valid=zone.getRules().getValidOffsets(at);
if(valid.isEmpty() || offset==null && valid.size()!=1)throw conflict("预约收货时间的时区偏移尚未明确");
var selected=offset==null?valid.get(0):java.time.ZoneOffset.of(offset);
if(!valid.contains(selected))throw conflict("预约收货时间的时区偏移无效");
return at.toInstant(selected);
} catch(java.time.DateTimeException error) {throw conflict("预约收货时间的时区或偏移无效");}
}
private void checkDeparture(String dispatchNo,boolean locked,boolean picked) {
var rows=mapper.bindings(dispatchNo);
var physical=mapper.dispatchPhysical(dispatchNo);
for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId())))
@@ -158,6 +204,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
if(locked) for(var batch:batches) lock(batch);
for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) {
requireNoBatch(item.customerId(),item.planPalletId());
if(!picked && mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")))throw conflict("本车FBA货物尚未关联已确认预约,请客服核对送达时间后提交审核");
if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车");
}
for(var batch:batches) {
@@ -175,8 +222,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) {
var first=group.get(0);
var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow();
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()),
group.stream().map(a->allocation(a,null)).toList(),true,true);
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),item.sourceStage(),item.packages()),
group.stream().map(a->allocation(a,null)).toList(),picked || "RECEIVED".equals(item.sourceStage()),picked);
}
}
private void requireReceiving(String batch,String customer,List<AppointmentWorkDispatchMapper.Bound> rows) {
@@ -19,6 +19,7 @@ import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation;
import com.baiyue.lastleg.client.OrderPreDepartureClient;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
@@ -42,6 +43,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@@ -87,12 +89,23 @@ public class LastlegOrderService {
@Resource private ContainerShippingGateMapper containerShippingGates;
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
@Resource private OrderPreDepartureClient orderPreDepartureClient;
@Value("${baiyue.security.internal-service-token:}") private String internalServiceToken;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null);
}
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) {
Long customer = parseLong(customerId);
var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(),
query.page().intValue(), query.size().intValue());
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
List<LastlegOrderSummary> rows = response.data().records().stream().map(this::preDepartureSummary).toList();
return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages());
}
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
@@ -112,6 +125,53 @@ public class LastlegOrderService {
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
/** 全部页签合并订单服务的未开船头程数据与本服务已有尾程单,尾程记录优先覆盖同一来源订单。 */
private PageResult<LastlegOrderSummary> aggregateAllPage(String customerId, LastlegOrderRequests.Query query) {
var source = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId), query.orderNo(), query.inboundCode(), 1, 2000);
if (source == null || source.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
List<LastlegOrder> existing = orderMapper.selectList(filter(customerId, query, null)
.orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
Map<String, String> existingNodes = containerNodes(existing);
Map<Long, LastlegOrderMapper.OrderWeightRow> existingWeights = orderWeights(existing);
Map<String, LastlegOrderSummary> merged = new LinkedHashMap<>();
source.data().records().forEach(row -> {
var summary = preDepartureSummary(row);
merged.put(orderKey(summary.customerId(), summary.sourceOrderNo()), summary);
});
for (LastlegOrder order : existing) {
String key = orderKey(order.getCustomerId(), StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo());
merged.put(key, summary(order, List.of(), StringUtils.hasText(order.getSourceContainerBookingId())
? existingNodes.get(order.getSourceContainerBookingId()) : null, "UNSCHEDULED",
existingWeights.get(order.getId()), List.of(), null, null));
}
List<LastlegOrderSummary> rows = merged.values().stream().sorted(java.util.Comparator.comparing(LastlegOrderSummary::createdAt,
java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))).toList();
int from = Math.min((query.page().intValue() - 1) * query.size().intValue(), rows.size());
int to = Math.min(from + query.size().intValue(), rows.size());
long total = rows.size();
return new PageResult<>(rows.subList(from, to), query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
private static String orderKey(String customerId, String orderNo) {
return String.valueOf(customerId) + ":" + String.valueOf(orderNo);
}
/** 将订单服务权威头程摘要投影为只读尾程列表行;尚未生成尾程单的字段保持为空。 */
private LastlegOrderSummary preDepartureSummary(OrderPreDepartureClient.Row row) {
LastlegOrder order = new LastlegOrder();
// 头程聚合行尚未生成尾程单,尾程订单号必须保持为空;来源头程订单号单独保留。
order.setId(row.orderId()); order.setOrderNo(null); order.setOrderType("FIRST_LEG");
order.setInboundCode(row.inboundCode()); order.setLxCode(row.lxCode()); order.setSourceOrderNo(row.orderNo());
order.setCustomerId(row.customerId() == null ? null : String.valueOf(row.customerId())); order.setCustomerName(row.customerName());
order.setSourceWarehouseName(row.receivingWarehouseName()); order.setOverseasWarehouseName(row.receivingWarehouseName());
order.setFbaWarehouseCode(row.destinationCode());
order.setContainerNo(row.containerNo()); order.setPackageCount(row.packageCount()); order.setVolumeCbm(row.volumeCbm());
order.setEstimatedArrivalAt(row.expectedArrivalAt()); order.setCurrentStatus(row.mainStatus());
order.setCreatedAt(row.createdAt()); order.setUpdatedAt(row.updatedAt()); order.setVersion(0);
return summary(order, List.of(), null, "UNSCHEDULED", null, List.of(), null, null);
}
@Transactional(readOnly = true)
public PageResult<ContainerPlanProgressSummary> containerProgressPage(
LastlegOrderRequests.ContainerProgressQuery query) {
@@ -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);
}
@@ -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);}
}
@@ -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,"转运单已开始收货或关联状态变化,不能取消排车及转运单,请刷新核对");}
}
@@ -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,3 @@
-- 本次仅新增排车内部余位发布选项;执行需单独授权,不回填、清理或重置业务数据。
ALTER TABLE ll_dispatch_order
ADD COLUMN publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位' AFTER task_type;
@@ -1895,6 +1895,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
@@ -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: 送仓确认必填,必须为本票本车新上传的到仓图片}
@@ -602,8 +602,8 @@ components:
properties:
warehousePalletId: {type: string}
palletSpaces: {type: integer, minimum: 1}
weightKg: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999}
volumeCbm: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999}
weightKg: {type: number, nullable: true, description: 已确认装车的未知实测保持空值;计划容量输入仍须填写正数, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999}
volumeCbm: {type: number, nullable: true, description: 已确认装车的未知实测保持空值;计划容量输入仍须填写正数, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999}
deliveryIndex: {type: integer, minimum: 1, description: 卸货站索引,起运仓固定为0}
PlanningEnvelope:
allOf:
@@ -641,8 +641,8 @@ components:
type: object
properties:
palletSpaces: {type: integer}
weightKg: {type: number}
volumeCbm: {type: number}
weightKg: {type: number, nullable: true, description: 实测不完整时未知,不表示零重量或可售余量}
volumeCbm: {type: number, nullable: true, description: 实测不完整时未知,不表示零体积或可售余量}
ConfirmLoad:
type: object
required: [recordVersion, manifestHash, pallets, reason]
@@ -4,6 +4,35 @@ info:
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
version: 1.0.0
paths:
/api/platform/v1/last-leg/dispatches/capacity-demands:
get:
summary: 读取有效派送排车的内部散板余位需求
description: 仅平台内部capacity-offer:list或manage可读;返回运输摘要,不返回客户、司机私人资料。实时排除未勾选、取货、本土卡车、已发车、取消及已知满载排车;缺测余位明确未知。
operationId: listDispatchCapacityDemands
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
- { name: origin, in: query, schema: { type: string } }
- { name: destination, in: query, schema: { type: string } }
responses:
'200':
description: 统一业务响应,data为包含records、page、size、total和pages的分页结果。
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data:
type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/DispatchCapacityDemand' } }
page: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
size: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
total: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
pages: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/increase-forecast-cartons:
post:
summary: 测量完成前增加预报箱数并补充箱唛
@@ -1728,8 +1757,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:
@@ -3961,6 +3990,10 @@ components:
type: object
x-java-record: DispatchCommands.Update
properties:
publishLooseboardDemand:
type: boolean
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
recordVersion:
oneOf:
- type: string
@@ -3990,6 +4023,7 @@ components:
type: string
nullable: true
required:
- publishLooseboardDemand
- recordVersion
- idempotencyKey
DeliveryStatusChange:
@@ -5253,6 +5287,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
@@ -5770,10 +5817,31 @@ components:
required:
- sourceType
- sourceVersion
DispatchCapacityDemand:
type: object
x-java-record: DispatchCapacityDemands.Demand
properties:
dispatchNo: { type: string }
warehouseName: { type: string, nullable: true }
route: { type: string, nullable: true }
plannedDepartureAt: { type: string, format: date-time, nullable: true }
vehiclePalletSpaces: { type: integer, nullable: true }
scheduledPalletCount: { type: integer, nullable: true }
remainingPalletSpaces: { type: integer, nullable: true }
remainingWeightKg: { type: number, nullable: true }
remainingVolumeCbm: { type: number, nullable: true }
capacityKnown: { type: boolean }
capacityReason: { type: string, nullable: true }
required: [dispatchNo, capacityKnown]
DispatchCreate:
type: object
x-java-record: DispatchCommands.Create
properties:
publishLooseboardDemand:
type: boolean
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
default: true
idempotencyKey:
type: string
warehouseId:
@@ -6010,18 +6078,13 @@ components:
manifestHash:
type: string
nullable: true
measurements:
type: array
items:
$ref: '#/components/schemas/DispatchLoadingMeasurement'
nullable: true
remark:
type: string
nullable: true
required:
- recordVersion
- idempotencyKey
description: 必须先由真实PDA拣货核平;这里只确认实载,不代填拣货箱数,不等于实际发车。
description: 必须先由真实PDA拣货核平;按已扫实托确认装车;重量体积由服务端已有测量带出,缺失不阻塞,不接受现场补填测量或箱数。
DispatchDepart:
type: object
x-java-record: DispatchCommands.Depart
@@ -6467,6 +6530,10 @@ components:
type: object
x-java-record: DispatchViews.Detail
properties:
publishLooseboardDemand:
type: boolean
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
dispatchNo:
type: string
nullable: true
@@ -6522,6 +6589,7 @@ components:
$ref: '#/components/schemas/DispatchOrderDetail'
nullable: true
required:
- publishLooseboardDemand
- dispatchNo
- status
- stage
@@ -6576,6 +6644,21 @@ components:
allOf:
- $ref: '#/components/schemas/DispatchCounts'
nullable: true
plannedDepartureAt:
type: string
format: date-time
nullable: true
description: 当前排车计划发车时间,审核必须核对。
estimatedArrivalAt:
type: string
format: date-time
nullable: true
description: 当前排车预计送达时间。
fbaAppointments:
type: array
items:
$ref: '#/components/schemas/DispatchFbaAppointment'
description: 按本车各客户批次读取当前有效收货确认;缺失确认显示PENDING。
pickingReconciled:
type: boolean
items:
@@ -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:
@@ -123,7 +123,7 @@ class PlatformDispatchOutboundContractTest {
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
"plannedDepartureAt", "estimatedArrivalAt", "remark"),
"plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand"),
Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
@@ -38,7 +38,7 @@ class DispatchSchemaContractTest {
"ll_dispatch_pda_task",com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask.class);
for(var entry:tables.entrySet()) {
String body=table(sql,entry.getKey());var columns=new TreeSet<String>();
var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body);
var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|TINYINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body);
while(matcher.find())columns.add(matcher.group(1));
var fields=new TreeSet<String>();for(var field:entry.getValue().getDeclaredFields())if(!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic())fields.add(field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT));
assertThat(columns).as(entry.getKey()).isEqualTo(fields).doesNotContain("customer_id");
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.when;
class DispatchQueryControllerTest {
@Mock DispatchAggregateService service;
@Mock DispatchBolPrintService printService;
@Mock com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
private DispatchQueryController controller;
@BeforeEach
@@ -36,6 +37,7 @@ class DispatchQueryControllerTest {
controller = new DispatchQueryController();
ReflectionTestUtils.setField(controller, "aggregateService", service);
ReflectionTestUtils.setField(controller, "printService", printService);
ReflectionTestUtils.setField(controller, "capacityDemands", capacityDemands);
}
@Test
@@ -72,6 +74,19 @@ class DispatchQueryControllerTest {
assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
}
/** 需求池使用独立列表权限且只允许平台身份,不向客户开放摘要来源。 */
@Test void capacityDemandPoolUsesItsOwnPermissionAndRejectsMerchantIdentity() throws Exception {
var query=new com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query(1,20,null,null);
var permission=DispatchQueryController.class.getMethod("capacityDemands",AuthenticatedUser.class,query.getClass()).getAnnotation(PreAuthorize.class);
assertThat(permission.value()).isEqualTo("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')");
when(capacityDemands.page(query)).thenReturn(new PageResult<>(List.of(),1,20,0,0));
assertThat(controller.capacityDemands(platform(),query).data().records()).isEmpty();
var merchant=new AuthenticatedUser(7L,9L,9L,8L,"customer","MERCHANT","WEB",Set.of());
assertThatThrownBy(()->controller.capacityDemands(merchant,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(403));
assertThatThrownBy(()->controller.capacityDemands(null,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(401));
verify(capacityDemands).page(query);
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of());
}
@@ -39,7 +39,7 @@ public final class DispatchPublicJsonContract {
rejectsMissing(requests, request, "{}");
rejectsMissing(requests, request, "{\"recordVersion\":null}");
rejectsMissing(requests, request, "{\"recordVersion\":-1}");
Object zero = requests.readValue("{\"recordVersion\":0}", request);
Object zero = requests.readValue(request == DispatchCommands.Update.class ? "{\"recordVersion\":0,\"publishLooseboardDemand\":true}" : "{\"recordVersion\":0}", request);
require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法");
}
rejectsMissing(requests, DispatchCommands.Update.class,
@@ -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网关合同分别覆盖。 */
@@ -185,7 +204,7 @@ public class DispatchAggregatePersistenceTest {
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注");
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
@@ -225,7 +244,7 @@ public class DispatchAggregatePersistenceTest {
}
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划");
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
@@ -264,10 +283,8 @@ public class DispatchAggregatePersistenceTest {
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
assertThat(preview.pallets().get(0).weightKg()).isEqualByComparingTo("100");
assertThat(preview.pallets().get(0).evidence()).allMatch(DispatchPlanningModels.MeasurementEvidence::actual);
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm","palletSpaces");
var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1,
p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList();
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对");
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm");
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),"实际核对");
// 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。
org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing()
.when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class),
@@ -279,6 +296,10 @@ public class DispatchAggregatePersistenceTest {
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero();
var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class);
var facts=((DispatchResults.Loaded)loaded.result()).confirmedLoad();
assertThat(facts.pallets()).hasSize(2);
assertThat(facts.pallets().get(0).weightKg()).isEqualByComparingTo("100");
assertThat(facts.pallets().get(1).weightKg()).isNull();assertThat(facts.pallets().get(1).volumeCbm()).isNull();
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result());
@@ -297,7 +318,7 @@ public class DispatchAggregatePersistenceTest {
f.db.update("UPDATE ll_dispatch_pda_pick_order SET packages=2");
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isFalse();assertThat(preview.loadingComplete().allowed()).isFalse();
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
}
/** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */
@@ -433,8 +454,14 @@ public class DispatchAggregatePersistenceTest {
f.db.update("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",dispatch,item);
var detail=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo()));
assertThat(detail.orderDetails()).hasSize(2); assertThat(detail.dispatchInfo().appointmentBatches()).hasSize(1);
assertThat(detail.dispatchInfo().fbaAppointments().get(0).appointmentNo()).isEqualTo("CONFIRM-REAL");
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isEqualTo(Instant.parse("2026-10-19T10:00:00Z"));
// 批次头写SUCCESS不代表存在当前有效收货确认,不再把旧头时间作为审批依据。
assertThat(detail.dispatchInfo().fbaAppointments().get(0).status()).isEqualTo("PENDING");
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isNull();
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
when(provider.reviewEvidence(original.dispatchNo())).thenReturn(List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-EXISTING","101","FBA:ONT8","CURRENT-CONFIRM",Instant.parse("2026-10-20T17:00:00Z"),null,"AGR-1",1)));
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().get(0);
assertThat(current.appointmentNo()).isEqualTo("CURRENT-CONFIRM");assertThat(current.confirmedAt()).isEqualTo(Instant.parse("2026-10-20T17:00:00Z"));
var c=f.source(3,"101",503,"ONT8",2);
f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of())));
var restored=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b))));
@@ -583,7 +610,7 @@ public class DispatchAggregatePersistenceTest {
}
/** 空计划和资源是合法待完善事实,不补假日期或主档。 */
public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of()); }
public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of(), true); }
/** 临时资源不创建主档;文件提供者在此夹具隔离,归属HTTP网关另行验证。 */
public DispatchCommands.Create configured(String key,List<DispatchCommands.CargoSelection> sources) {
@@ -591,8 +618,8 @@ 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))),
departure,null,null,sources,List.of());
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(), true);
}
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
@@ -611,6 +638,6 @@ public class DispatchAggregatePersistenceTest {
}
/** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */
private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; }
private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Boolean.class||type==boolean.class?"BOOLEAN":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; }
}
}
@@ -40,6 +40,6 @@ class DispatchAppointmentAllocationsTest {
when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整");
}
private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List<BatchAllocation> allocations) {
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations);
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations, true);
}
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class DispatchCapacityDemandPersistenceTest {
/** 默认发布和取消发布随原排车命令同事务保存,原请求重放不重复修改。 */
@Test void defaultsAndUnpublishesThroughTheExistingVersionedCommand() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var request=f.json.readValue("{\"idempotencyKey\":\"pool-default\",\"warehouseId\":\"9\",\"cargo\":[],\"batchAllocations\":[]}",DispatchCommands.Create.class);
var created=f.tx.execute(s->f.service.create(f.actor,request));
var service=new DispatchCapacityDemandService(f.repository,f.json);
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo());
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue();
var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse();
assertThat(service.page(query()).total()).isZero();
var replay=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());
assertThat(service.page(query()).total()).isZero();
}
/** 余位缺测时保持未知;不能将实托数量当标准托位或使用默认三十托。 */
@Test void unknownCapacityIsVisibleAndNonDeliveryOrDepartedRowsAreExcluded() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var service=new DispatchCapacityDemandService(f.repository,f.json);
var created=f.tx.execute(s->f.service.create(f.actor,f.create("pool-unknown",List.of())));
var demand=service.page(query()).records().get(0);
assertThat(demand.capacityKnown()).isFalse();assertThat(demand.remainingPalletSpaces()).isNull();
assertThat(demand.remainingWeightKg()).isNull();assertThat(demand.capacityReason()).isNotBlank();
for(var type:List.of("PICKUP","LOCAL_TRUCK")) {
f.db.update("UPDATE ll_dispatch_order SET task_type=?",type);assertThat(service.page(query()).total()).isZero();
}
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',stage='DELIVERING'");assertThat(service.page(query()).total()).isZero();
f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING',status='CANCELLED'");assertThat(service.page(query()).total()).isZero();
}
/** 真实装载去重后计算三维余位,已知满载在分页前排除。 */
@Test void computesConfirmedCapacityAndRemovesFullLoadsBeforePagination() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var service=new DispatchCapacityDemandService(f.repository,f.json);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pool-known",List.of())));
var load=new ConfirmedLoad("test-manifest",List.of(new LoadingMeasurement(101L,3,new BigDecimal("400"),new BigDecimal("5"),1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?,stage='AFTER_LOADING',pallet_count=1",f.json.writeValueAsString(load));
var demand=service.page(query()).records().get(0);
assertThat(demand.capacityKnown()).isTrue();assertThat(demand.remainingPalletSpaces()).isEqualTo(12);
assertThat(demand.remainingWeightKg()).isEqualByComparingTo("9600");assertThat(demand.remainingVolumeCbm()).isEqualByComparingTo("45");
// 同一张实托占三个标准托位,不能按实托一件减一。
assertThat(demand.scheduledPalletCount()).isOne();
var full=new ConfirmedLoad("full",List.of(new LoadingMeasurement(101L,15,new BigDecimal("400"),new BigDecimal("5"),1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(full));
assertThat(service.page(query()).total()).isZero();assertThat(service.page(query()).records()).isEmpty();
}
/** 重量或体积缺测不掩盖已明确的托位满载。 */
@Test void knownFullPalletSpacesHideEvenWithUnknownWeight() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
f.tx.execute(s->f.service.create(f.actor,f.configured("pool-full-slots",List.of())));
var load=new ConfirmedLoad("partial",List.of(new LoadingMeasurement(101L,15,null,null,1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(load));
assertThat(new DispatchCapacityDemandService(f.repository,f.json).page(query()).total()).isZero();
}
/** 新建时可以显式取消默认发布,后续不产生额外发布记录。 */
@Test void createCanOptOutAndFiltersUseTheActualWarehouseAndRoute() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var initial=f.create("pool-opt-out",List.of());
var request=new DispatchCommands.Create(initial.idempotencyKey(),initial.warehouseId(),null,null,null,null,null,List.of(),List.of(),false);
f.tx.execute(s->f.service.create(f.actor,request));
var service=new DispatchCapacityDemandService(f.repository,f.json);assertThat(service.page(query()).total()).isZero();
f.db.update("UPDATE ll_dispatch_order SET publish_looseboard_demand=TRUE,overseas_warehouse_name='测试仓',route='测试仓 → ONT8'");
assertThat(service.page(new DispatchCapacityDemands.Query(1,20,"测试","ONT8")).total()).isOne();
assertThat(service.page(new DispatchCapacityDemands.Query(1,20,"其它仓",null)).total()).isZero();
}
/** 使用与正式列表相同的分页输入。 */
private DispatchCapacityDemands.Query query() { return new DispatchCapacityDemands.Query(1,20,null,null); }
}
@@ -64,7 +64,7 @@ class DispatchDeparturePersistenceTest {
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("depart-create",List.of(a,b))));number=created.dispatchNo();var approved=f.approve(created);
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
var loading=f.tx.execute(s->f.service.loading(f.actor,number));var measurements=loading.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),measurements,"实际实载")));
f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),"实际实载")));
var context=f.tx.execute(s->f.service.preview(f.actor,number));
outbounds=List.of(new OutboundReference("101","测试客户101","OUT-A","WAIT_OUTBOUND",3,1),new OutboundReference("202","测试客户202","OUT-B","WAIT_OUTBOUND",5,1));
deliveryRefs=List.of(new DeliveryReference("GROUP-A","101","测试客户101","OUT-A","DL-A",context.cargo().get(0).destination(),"WAIT_DELIVERY",3,1),new DeliveryReference("GROUP-B","202","测试客户202","OUT-B","DL-B",context.cargo().get(1).destination(),"WAIT_DELIVERY",5,1));
@@ -326,7 +326,7 @@ class DispatchDraftPersistenceTest {
@Test void conversionKeepsWholeSharedGroupAndOriginalCommandIdentity(){
relabelPair();var state=select(empty(1),0,1);var full=conversion(state,"convert-whole");var vehicle=full.groups().get(0);var create=vehicle.dispatch();
var partial=new DispatchCommands.Create("partial-child",create.warehouseId(),create.vehicle(),create.driver(),create.plannedDepartureAt(),
create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations());
create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations(), true);
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(state.recordVersion(),"partial-convert",state.draftToken(),List.of(new DraftVehicle(vehicle.draftContainerId(),partial)))))
.isInstanceOf(BizException.class);
assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero();
@@ -368,7 +368,7 @@ class DispatchDraftPersistenceTest {
var state=select(empty(2),0,1);state=select(state,1,2);var original=conversion(state,"convert");
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"partial",original.draftToken(),original.groups().subList(0,1)))).isInstanceOf(BizException.class);
var first=original.groups().get(0);var r=first.dispatch();
var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations());
var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations(), true);
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"foreign-wh",original.draftToken(),List.of(new DraftVehicle(first.draftContainerId(),foreign),original.groups().get(1))))).isInstanceOf(BizException.class);
clearInvocations(aggregate);versions.put(1L,"changed");
assertThatThrownBy(()->service.convert(owner,original)).isInstanceOf(IllegalStateException.class);
@@ -554,7 +554,7 @@ class DispatchDraftPersistenceTest {
}
/** 每个非空服务器容器生成一组完整创建请求。 */
private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList<DraftVehicle>();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null,
draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of())));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);}
draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of(), true)));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);}
/** 主单边界返回冻结的真实公开结果形状。 */
private static CommandResult result(String no,String key,int cargo){return new CommandResult(no,Action.CREATE,key,0,Status.ACTIVE,Stage.BEFORE_LOADING,Instant.now(),Effect.APPLIED,new Counts(cargo,cargo,10L*cargo,0,0),new DispatchResults.Saved(no,List.of()),false,false);}
/** 测试独立命令键不依赖执行顺序或系统时间。 */
@@ -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");
}
@@ -65,15 +84,15 @@ class DispatchLoadingDeparturePersistenceTest {
@Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception {
authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9");
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓");
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",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,10 +224,26 @@ 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,"实装发车");}
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,"实装发车");}
/** 两客户子出库保持独立业务号和箱数。 */
List<OutboundReference> outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));}
/** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */
@@ -19,6 +19,64 @@ import static org.mockito.Mockito.*;
/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */
class DispatchLoadingEligibilityPersistenceTest {
@org.junit.jupiter.api.BeforeEach void authorize() {
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete",
"platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
}
@org.junit.jupiter.api.AfterEach void clearIdentity(){org.springframework.security.core.context.SecurityContextHolder.clearContext();}
/** 允许预排;客服提交及两级批准均复验,缺预约时仍能驳回客服而不能开始拣货。 */
@org.junit.jupiter.api.Test void preArrangementRemainsAllowedButReviewsCannotApproveUnconfirmedAppointments() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("review-appointment",List.of(f.source(1,"101",501,"ONT8",3)))));
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"blocked-submit",null)))).hasMessageContaining("尚未确认");
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
var submitted=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
var gate=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.DISPATCH_REVIEW);
assertThat(gate.allowed()).isTrue();assertThat(gate.blockCode()).isEqualTo("APPROVAL_BLOCKED");
assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认");
// 预约依据变化后仍可驳回,不让失效审核把客服修正入口锁死。
var changedWindow=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","YY1","101","FBA:ONT8","CONF1",java.time.Instant.parse("2026-12-01T10:00:00Z"),null,"agreement-1",2);
when(provider.reviewEvidence(anyString())).thenReturn(List.of(changedWindow));
var rejected=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"reject",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.REJECTED,"请客服核对预约",null)));
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
var submittedAgain=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"resubmit",null)));
var reviewed=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submittedAgain.recordVersion(),"dispatch-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)));
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"bad-loading-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认");
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)));
when(provider.reviewEvidence(anyString())).thenReturn(List.of());
assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("原审核依据失效");
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
assertThat(f.tx.execute(t->f.service.detail(pda,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.PICK).allowed()).isFalse();
assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("尚未确认");
}
/** 照片或终端不改变预约门槛;显示与正式提交均拒绝未准备好的预约。 */
@org.junit.jupiter.api.Test void appointmentBlockIsVisibleBeforeLoadingSubmission() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-gate",List.of(f.source(1,"101",501,"ONT8",3)))));
var approved=f.approve(created);f.picked(1,"101",501,3);
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=3");
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
when(provider.departureBlockReason(created.dispatchNo())).thenReturn("请先约仓成功再排车或发车");
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isTrue();
assertThat(preview.loadingComplete().allowed()).isFalse();
assertThat(preview.loadingComplete().blockCode()).isEqualTo("APPOINTMENT_NOT_READY");
assertThat(f.tx.execute(t->f.service.detail(actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.LOADING_COMPLETE).allowed()).isFalse();
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"blocked-loading",preview.manifestHash(),null))))
.hasMessageContaining("先约仓成功");
assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("LOADING");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion());
}
/** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */
@ParameterizedTest
@ValueSource(booleans={false,true})
@@ -50,7 +108,7 @@ class DispatchLoadingEligibilityPersistenceTest {
"ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock",
"ll_fleet_trip","ll_fleet_trip_history");
var before=snapshot(f,tables);
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例");
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),"未实收负例");
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request)))
.isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT))
.hasMessage("逐项真实拣货尚未核平");
@@ -96,7 +96,7 @@ class DispatchMergedRelabelPersistenceTest {
var preview=f.f.service.loading(f.f.actor,created.dispatchNo());assertThat(preview.pallets()).hasSize(1);assertThat(preview.pallets().get(0).scheduledPackages()).isEqualTo(5);
var pallet=preview.pallets().get(0);
var loaded=f.f.tx.execute(s->f.f.service.loadingComplete(f.picker,created.dispatchNo(),new DispatchCommands.LoadingComplete(last.recordVersion(),"load",preview.manifestHash(),
List.of(new DispatchContracts.LoadingMeasurement(501L,1,new BigDecimal("10"),BigDecimal.ONE,pallet.deliveryIndex()==null?1:pallet.deliveryIndex())),null)));
null)));
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);
assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2);
}
@@ -31,6 +31,10 @@ class DispatchPdaReadPersistenceTest {
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,null)));
var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1);
var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING);
var platform=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
assertThat(task.plannedDepartureAt()).isEqualTo(platform.dispatchInfo().plannedDepartureAt());
assertThat(task.estimatedArrivalAt()).isEqualTo(platform.dispatchInfo().estimatedArrivalAt());
assertThat(task.fbaAppointments()).isEqualTo(platform.dispatchInfo().fbaAppointments());
assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse();
assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机");
f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符",null)));
@@ -23,6 +23,55 @@ import static org.mockito.Mockito.*;
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
class DispatchPickingPersistenceTest {
/** 即使旧记录已经审核通过,真实扫码仍复验预约;拒绝时不物化、不扣库存。 */
@Test void unconfirmedAppointmentBlocksPickingBeforeInventoryWrites() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
org.springframework.test.util.ReflectionTestUtils.setField(fixture.f.service,"appointments",new DispatchAppointmentAllocations(provider));
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"appointment-blocked",11L,9,0,null,null)))).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);
}
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
@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 +117,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 +144,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)";}
@@ -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));}
@@ -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());
@@ -397,7 +397,7 @@ class DispatchRepairWorkflowTest {
}
/** 使用当前公开整车创建DTO,不恢复旧客户预排接口。 */
private DispatchCommands.Create create(List<DispatchCommands.CargoSelection> cargo) {
return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of());
return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of(), true);
}
/** 重放固定原版本及原键,取消旧单不会取得或释放新单占用。 */
private void cancel(String number) {
@@ -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(), "维护照片", true);
}
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
private static DispatchEvidenceGateway gateway(DispatchAggregatePersistenceTest.Fixture f) {
return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence");
}
}
@@ -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();
@@ -62,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest {
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
var resources=f.configured("updated-resources",List.of());
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正")));
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true)));
var detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
@@ -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);
}
}
@@ -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);
@@ -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);}
}
@@ -53,4 +53,18 @@ class TripCapacityPolicyTest {
@Test void emptyTripRetainsCapacityOnEachSegment() {
assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity));
}
@Test void thirtyActualPalletsWithoutMeasurementsDoNotBecomeZeroWeightOrSellableCapacity() {
var pallets=java.util.stream.IntStream.range(0,30).mapToObj(i->new Booking("P"+i,0,1,new Load(1,null,null))).toList();
var segment=calculateActual(2,capacity,pallets).get(0);
assertThat(segment.occupied().palletSpaces()).isEqualTo(30);
assertThat(segment.occupied().weightKg()).isNull();assertThat(segment.remaining().volumeCbm()).isNull();
assertThatThrownBy(()->calculate(2,capacity,pallets)).hasMessageContaining("重量和体积");
var tooMany=new java.util.ArrayList<>(pallets);tooMany.add(new Booking("EXTRA",0,1,new Load(1,null,null)));
assertThatThrownBy(()->calculateActual(2,capacity,tooMany)).hasMessageContaining("超过");
}
@Test void unknownPalletDoesNotHideKnownOverweight() {
assertThatThrownBy(()->calculateActual(2,capacity,List.of(new Booking("UNKNOWN",0,1,new Load(1,null,null)),
new Booking("KNOWN",0,1,load(1,"18001","1"))))).hasMessageContaining("超过");
}
}
@@ -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);
}
}
@@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest {
when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of(),true);
});
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
@@ -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();
}
}
@@ -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("车次已变化");
@@ -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); }
@@ -58,6 +58,16 @@ class AppointmentWorkDispatchBindingTest {
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4)));
var departure=java.time.Instant.parse("2026-10-18T12:00:00Z");
var arrival=java.time.Instant.parse("2026-10-18T17:00:00Z");
clearInvocations(mapper);
assertNull(service.reviewBlockReason("D-20",departure,arrival));
verify(mapper,never()).pickedPhysical(anyLong(),anyString());
assertEquals(arrival,service.reviewEvidence("D-20").get(0).startAt(),"10点洛杉矶预约应按当地时区解释");
assertNotNull(service.reviewBlockReason("D-20",departure,null));
assertNotNull(service.reviewBlockReason("D-20",arrival.plusSeconds(60),arrival.plusSeconds(120)));
assertNotNull(service.reviewBlockReason("D-20",departure,arrival.plusSeconds(60)));
when(item.packageCount()).thenReturn(5);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertTrue(service.departureBlockReason("D-20").contains("收货确认未完整覆盖")); // Batch has 5, but the current agreement covers only 4 across every dispatch.
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
@@ -76,6 +86,7 @@ class AppointmentWorkDispatchBindingTest {
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertTrue(service.reviewBlockReason("D-20",departure,arrival).contains("尚无有效收货确认"));
verifyNoInteractions(changes);
}
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) {
@@ -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]"));
@@ -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("不能取消");
}
}
@@ -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);
@@ -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
@@ -0,0 +1,56 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.order.dto.PreDepartureOrderDtos;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.service.UnifiedOrderQueryService;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
/** Service-only aggregate query. It intentionally has no platform-user permission. */
@RestController
@RequestMapping("/internal/order/v1/predeparture-orders")
public class InternalPreDepartureOrderController {
@Resource private UnifiedOrderQueryService service;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@GetMapping
public ApiResponse<PageResult<PreDepartureOrderDtos.Row>> page(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@RequestParam(required=false) Long customerId, @RequestParam(required=false) String orderNo,
@RequestParam(required=false) String inboundCode, @RequestParam(defaultValue="1") int page,
@RequestParam(defaultValue="200") int size) {
if (expectedToken == null || expectedToken.isBlank() || token == null || !java.security.MessageDigest.isEqual(expectedToken.getBytes(StandardCharsets.UTF_8), token.getBytes(StandardCharsets.UTF_8))) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.FORBIDDEN);
if (page < 1 || size < 1 || size > 2000) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST);
Map<Long, UnifiedOrderResponse.Row> all = new LinkedHashMap<>();
// 由统一订单服务一次返回全部履约状态,再按实际开船事实筛选。
for (long sourcePage = 1; ; sourcePage++) {
UnifiedOrderQuery q = new UnifiedOrderQuery(null, orderNo, "PRE_DEPARTURE", customerId, null, null,
inboundCode, null, null, null, null, null, null, null, null, null, null, null,
null, null, sourcePage, 200, null);
var result = service.platformPage(customerId, q);
result.records().forEach(row -> { if (row.transport() == null || row.transport().actualSailingAt() == null) all.putIfAbsent(row.orderId(), row); });
if (sourcePage >= result.pages()) break;
}
List<PreDepartureOrderDtos.Row> rows = all.values().stream().sorted(Comparator.comparing(UnifiedOrderResponse.Row::createdAt, Comparator.nullsLast(Comparator.reverseOrder())))
.map(this::row).toList();
int from = Math.min((page - 1) * size, rows.size());
int to = Math.min(from + size, rows.size());
long total = rows.size(), pages = total == 0 ? 0 : (total + size - 1) / size;
return ApiResponse.success(new PageResult<>(rows.subList(from, to), page, size, total, pages));
}
private PreDepartureOrderDtos.Row row(UnifiedOrderResponse.Row r) {
var t = r.transport();
return new PreDepartureOrderDtos.Row(r.orderId(), r.customerId(), r.orderNo(), r.inboundCode(), r.mainStatus(),
r.customerName(), r.lxCode(), r.receivingWarehouseName(), r.destinationWarehouseName(), r.destinationCode(),
r.totalPackageCount(), r.actualVolumeCbm() == null ? r.expectedVolumeCbm() : r.actualVolumeCbm(),
r.actualGrossWeightKg(), r.totalNetWeightKg(), r.expectedArrivalAt(), t == null ? null : t.containerNo(),
t == null ? null : t.cabinetNo(), t == null ? null : t.estimatedSailingAt(), t == null ? null : t.actualSailingAt(), r.createdAt(), r.updatedAt());
}
}
@@ -0,0 +1,17 @@
package com.baiyue.order.dto;
import java.time.LocalDateTime;
/** Internal read model used by lastleg to show orders before an actual sailing fact exists. */
public final class PreDepartureOrderDtos {
private PreDepartureOrderDtos() {}
public record Query(Long customerId, String orderNo, String inboundCode, int page, int size) {}
public record Row(Long orderId, Long customerId, String orderNo, String inboundCode,
String mainStatus, String customerName, String lxCode,
String receivingWarehouseName, String destinationWarehouseName,
String destinationCode, Integer packageCount, java.math.BigDecimal volumeCbm,
java.math.BigDecimal grossWeightKg, java.math.BigDecimal netWeightKg,
LocalDateTime expectedArrivalAt, String containerNo, String cabinetNo,
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
LocalDateTime createdAt, LocalDateTime updatedAt) {}
}

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