fix(dropship): generate pallet numbers atomically and extend full regressions

This commit is contained in:
2026-09-09 12:19:16 +08:00
100 changed files with 2889 additions and 234 deletions
+4
View File
@@ -37,6 +37,10 @@ export async function run(context) {
## 可用上下文
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
一件代发的 `inbound-measurement` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。
- `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。
- `requestBinary(method, path)`:文件下载,不把二进制写入报告。
- `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。
@@ -224,9 +224,9 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
if (!requirement?.id) return null
for (let index = 0; index < chains.length; index += 1) {
const chain = chains[index]
const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`)
const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${chain.containerBookingId}`)
const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse,
evidence(detailResponse), OPTIONS)
evidence(detailResponse), OPTIONS)?.container
const containerNo = iso6346ContainerNumber(`${context.marker}-coload-${index + 1}`)
const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), containerNo,
@@ -1,9 +1,52 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { containerWorkbenchGaps } from './phase3-container-loading.mjs'
const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8')
test('workbench DTO rejects the old flat entity and incomplete action/state projections', () => {
const container = { id: '71', cabinetNo: 'CB-20260909-01', version: 0 }
const row = { container, workbenchStage: 'WAIT_LOADING', statusSummary: {}, allowedActions: [] }
assert.deepEqual(containerWorkbenchGaps(row), [])
assert.ok(containerWorkbenchGaps(container).includes('container.id'))
for (const field of ['container', 'workbenchStage', 'statusSummary', 'allowedActions']) {
const invalid = { ...row }
delete invalid[field]
assert.ok(containerWorkbenchGaps(invalid).length > 0, `missing ${field} must fail`)
}
assert.ok(containerWorkbenchGaps({ ...row, container: { ...container, version: null } }).includes('container.version'))
})
test('Admin read routes match controller workbench endpoints and default HTTP regression exercises the page DTO', () => {
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java')
const admin = read('../../../apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts')
assert.match(controller, /@GetMapping\("\/domestic"\)/)
assert.match(controller, /@GetMapping\("\/domestic\/\{id\}"\)/)
assert.match(controller, /ApiResponse<PageResult<WorkbenchItem>>/)
assert.match(admin, /listContainers\([^\n]*workbench: ContainerWorkbenchKind = 'DOMESTIC'/)
assert.ok(admin.includes('`${containerPath}/${workbench.toLowerCase()}`'))
assert.ok(admin.includes('`${containerPath}/${workbench.toLowerCase()}/${encodeURIComponent(containerId)}`'))
const loading = read('./phase3-container-loading.mjs')
assert.ok(loading.includes('size=20&scope=ACTIVE&status='))
assert.ok(loading.includes('containerWorkbenchGaps(containerDetailResponse.body?.data)'))
assert.ok(loading.includes('records(page).every((item) => containerWorkbenchGaps(item).length === 0)'))
assert.ok(!loading.includes('value?.container || value'))
})
test('manual and coload consumers read domestic WorkbenchItem rather than the removed flat routes', () => {
for (const name of ['manual-loading-complete-fixture', 'manual-order-splitting-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'coload-groups']) {
const source = read(`./${name}.mjs`)
assert.ok(source.includes("context.request('GET', `${CONTAINERS}/domestic/${"), name)
assert.ok(!source.includes("context.request('GET', `${CONTAINERS}/${"), name)
assert.match(source, /OPTIONS\)\?\.container/, name)
}
const states = read('./manual-state-loading-pda.mjs')
assert.ok(states.includes('`${CONTAINERS}/domestic?scope=ACTIVE&status='))
assert.ok(states.includes('item.container?.planStatus === status'))
})
test('domestic container fixtures use the current workbench detail route', () => {
const containerLoading = read('./phase3-container-loading.mjs')
assert.ok(containerLoading.includes("context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)"))
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { completeDropshipPalletization } from './inbound-measurement.mjs'
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
@@ -102,7 +103,10 @@ test('dropship measurement completes the formal palletization workflow before sm
assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/)
assert.match(source, /cartons\/resolve/)
assert.match(source, /cartons\/count/)
assert.match(source, /pallets\/bind-pending/)
assert.match(source, /pallets\/create-and-bind/)
assert.match(source, /系统发码重放不新增托盘且业务码和计数稳定/)
assert.match(source, /拒绝创建没有空托或计数漂移/)
assert.doesNotMatch(source, /palletHash|palletDate/)
assert.match(source, /pallets\/\$\{palletId\}\/seal/)
assert.match(source, /重复清点操作按operationId幂等返回/)
assert.doesNotMatch(source, /pallets\/open/)
@@ -117,3 +121,73 @@ test('confirmed package count reconciles an uncertain transport result before re
assert.match(source, /Number\(detail\?\.version\) > Number\(body\.measurementVersion\)/)
assert.match(source, /await updateConfirmedPackageCount\(context, measurementPath/)
})
async function exerciseSystemPalletScenario(fault = '') {
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [] }
const seenCounts = new Map()
const seenCreates = new Set()
const checks = []
const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) } })
const context = {
marker: 'fixture-system-pallet',
apiSuccess(_module, name, result) {
assert.equal(result.body.code, '200', name)
return result.body.data
},
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
async request(method, path, body) {
if (path.includes('/pending?')) return response([task])
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, expectedSkuQuantity: 10 })
if (path.endsWith('/cartons/count')) {
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
task.countedCartons += 1
const targetPallet = task.pallets.find(pallet => pallet.palletNo === body.palletNo)
if (body.palletNo) { task.boundCartons += 1; targetPallet.cartonCount += 1; targetPallet.version += 1 }
else task.unboundCartons += 1
const result = response({ ...task, carton: { boxCode: body.boxCode, expectedSkuQuantity: 10, actualSkuQuantity: 10,
discrepancyStatus: 'MATCH' }, pallet: body.palletNo ? targetPallet : null })
seenCounts.set(body.operationId, result)
return result
}
if (path.endsWith('/create-and-bind')) {
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'warehouseId'])
if (seenCreates.has(body.operationId)) {
if (fault === 'duplicate-replay') task.pallets.push({ ...task.pallets[0], palletId: '999', palletNo: 'DP-260909-0999' })
return response(task)
}
if (!task.unboundCartons) {
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'OPEN', version: 0 })
return response(null, 'A0409')
}
seenCreates.add(body.operationId)
task.pallets.push({ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: task.unboundCartons, status: 'OPEN', version: 0 })
task.boundCartons += task.unboundCartons
task.unboundCartons = 0
return response(task)
}
if (path.endsWith('/84/seal')) {
task.taskStatus = 'COMPLETED'
task.pallets.forEach(pallet => { pallet.status = 'SEALED'; pallet.version += 1 })
return response(task)
}
throw new Error(`Unexpected scenario request: ${method} ${path}`)
},
}
await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
return checks
}
test('system-issued pallet HTTP scenario covers count, replay, empty rejection and completed replay', async () => {
const checks = await exerciseSystemPalletScenario()
assert.ok(checks.length >= 15)
assert.deepEqual(checks.filter(check => !check.passed), [])
})
test('system-issued pallet HTTP regression detects duplicate replay and orphan empty pallets', async () => {
const replayChecks = await exerciseSystemPalletScenario('duplicate-replay')
assert.ok(replayChecks.some(check => check.name.includes('系统发码重放不新增托盘') && !check.passed))
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
assert.ok(emptyChecks.some(check => check.name.includes('拒绝创建没有空托或计数漂移') && !check.passed))
})
@@ -113,7 +113,7 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta
return { response, measurement, attempts }
}
async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
const pendingResponse = await context.request('GET',
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?keyword=${encodeURIComponent(inboundCode)}`)
const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse,
@@ -135,15 +135,31 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
`taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS)
const totalCartons = Number(measurement?.actualPackageCount)
const palletDate = new Date().toISOString().slice(2, 10).replace(/-/g, '')
let palletHash = 0
for (const character of `${context.marker}:${taskId}`) {
palletHash = (Math.imul(palletHash, 31) + character.charCodeAt(0)) >>> 0
}
const palletNo = `DP-${palletDate}-${String(palletHash % 10_000).padStart(4, '0')}`
const createPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/create-and-bind`
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) }
let palletNo = ''
let countedCartons = 0
let pallet = null
const palletSnapshot = (value) => JSON.stringify({
counted: value?.countedCartons, bound: value?.boundCartons, unbound: value?.unboundCartons,
pallets: value?.pallets?.map((item) => ({ id: asId(item.palletId), no: item.palletNo,
count: item.cartonCount, status: item.status, version: item.version })),
})
async function rejectEmptyCreation(before, suffix) {
const response = await context.request('POST', createPath,
{ ...createPayload, operationId: `${context.marker}-dropship-empty-${suffix}`.slice(0, 128) })
context.assert(MODULE, `无待绑箱创建托盘被拒绝-${suffix}`, response,
response.http === 200 && String(response.body?.code) === 'A0409',
`HTTP=${response.http}, code=${response.body?.code || 'missing'}`, OPTIONS)
const afterResponse = await context.request('GET', taskPath)
const after = context.apiSuccess(MODULE, `拒绝创建后回查任务-${suffix}`, afterResponse, '', OPTIONS)
context.assert(MODULE, `拒绝创建没有空托或计数漂移-${suffix}`, afterResponse,
palletSnapshot(after) === palletSnapshot(before), `pallets=${after?.pallets?.length ?? 'missing'}`, OPTIONS)
}
await rejectEmptyCreation(claimed, 'before-count')
for (let index = 0; index < totalCartons; index += 1) {
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
const resolveResponse = await context.request('GET',
@@ -189,18 +205,21 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
&& Number(replayed?.unboundCartons) === Number(counted?.unboundCartons),
`counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS)
const bindResponse = await context.request('POST',
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, {
warehouseId, palletNo,
operationId: `${context.marker}-dropship-bind-pending`.slice(0, 128),
})
const bound = context.apiSuccess(MODULE, '扫描托盘并绑定全部待组托箱', bindResponse,
`taskId=${taskId}, palletNo=${palletNo}`, OPTIONS)
pallet = bound?.pallets?.find((item) => item?.palletNo === palletNo) || null
const bindResponse = await context.request('POST', createPath, createPayload)
const bound = context.apiSuccess(MODULE, '系统发码创建托盘并绑定全部待组托箱', bindResponse,
`taskId=${taskId}`, OPTIONS)
pallet = bound?.pallets?.find((item) => Number(item.cartonCount) === 1) || null
palletNo = pallet?.palletNo || ''
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
&& pallet?.status === 'OPEN',
&& pallet?.status === 'OPEN' && /^DP-\d{6}-\d{4}$/.test(palletNo)
&& bound?.pallets?.length === 1,
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
const createReplayResponse = await context.request('POST', createPath, createPayload)
const createReplay = context.apiSuccess(MODULE, '系统发码同operationId重放', createReplayResponse, '', OPTIONS)
context.assert(MODULE, '系统发码重放不新增托盘且业务码和计数稳定', createReplayResponse,
palletSnapshot(createReplay) === palletSnapshot(bound), `palletNo=${palletNo}`, OPTIONS)
await rejectEmptyCreation(bound, 'after-bind')
} else {
pallet = counted?.pallet || pallet
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
@@ -229,6 +248,11 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
&& Number(sealed?.unboundCartons) === 0
&& sealed?.pallets?.every((item) => item?.status === 'SEALED'),
`taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS)
const sealedReplayResponse = await context.request('POST', createPath, createPayload)
const sealedReplay = context.apiSuccess(MODULE, '封托完成后系统发码原操作仍可重放', sealedReplayResponse, '', OPTIONS)
context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse,
sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed),
`taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS)
return sealed
}
@@ -86,8 +86,8 @@ export async function run(context) {
&& !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(source.containerBookingId)}`)
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(source.containerBookingId)}`)
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)?.container
const bookingResponse = container?.oceanBookingId
? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`)
: containerResponse
@@ -41,8 +41,8 @@ export async function run(context) {
OPTIONS)
if (!bookingId) return
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(bookingId)}`)
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(bookingId)}`)
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse,
id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId)
&& container?.channelCode === manifest.channelCode,
@@ -46,8 +46,8 @@ export async function run(context) {
OPTIONS)
if (!plan || beforeItems.length === 0) return
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo
&& container?.containerNo == null && container?.oceanBookingId == null
@@ -47,8 +47,8 @@ export async function run(context) {
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`,
OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)?.container
const oceanBookingId = id(container?.oceanBookingId)
const realContainerNo = String(container?.containerNo || '')
context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse,
@@ -47,8 +47,8 @@ export async function run(context) {
context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse,
rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse,
id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null
&& id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode,
@@ -87,8 +87,8 @@ export async function run(context) {
`HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`,
OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo
&& container?.containerNo == null && container?.oceanBookingId == null,
@@ -316,10 +316,10 @@ async function complete(context, chain, label) {
}
async function verifyContainer(context, status, fixture) {
const response = await context.request('GET', `${CONTAINERS}?status=${status}&page=1&size=100`)
const response = await context.request('GET', `${CONTAINERS}/domestic?scope=ACTIVE&status=${status}&page=1&size=100`)
const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS)
context.assert(MODULE, `${status} 货柜样本保留`, response,
records(page).some((item) => id(item.id) === fixture.id && item.planStatus === status),
records(page).some((item) => id(item.container?.id) === fixture.id && item.container?.planStatus === status),
`id=${fixture.id}`, OPTIONS)
}
@@ -22,7 +22,18 @@ function failure(response) {
|| (response?.http === 200 && codeOf(response) !== '200')
}
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function containerRecord(value) { return value?.container || value }
function containerRecord(value) { return value?.container }
export function containerWorkbenchGaps(value) {
return [
!value?.container?.id && 'container.id',
!value?.container?.cabinetNo && 'container.cabinetNo',
!Number.isInteger(value?.container?.version) && 'container.version',
!value?.workbenchStage && 'workbenchStage',
(!value?.statusSummary || typeof value.statusSummary !== 'object') && 'statusSummary',
!Array.isArray(value?.allowedActions) && 'allowedActions',
].filter(Boolean)
}
async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) {
let response = null
@@ -262,6 +273,18 @@ export async function run(context) {
const containerDetailResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
const containerDetail = containerRecord(context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS))
const workbenchGaps = containerWorkbenchGaps(containerDetailResponse.body?.data)
context.assert(MODULE, '货柜详情返回正式工作台 DTO 而非旧扁平实体', containerDetailResponse,
workbenchGaps.length === 0, `missing=${workbenchGaps.join('|') || 'none'}`, OPTIONS)
const pageResponse = await context.request('GET',
`${DOMESTIC_CONTAINERS}?page=1&size=20&scope=ACTIVE&status=${encodeURIComponent(containerDetail?.planStatus || '')}&keyword=${encodeURIComponent(containerDetail?.cabinetNo || '')}`)
const page = context.apiSuccess(MODULE, '按货柜页面筛选参数查询本轮模拟柜', pageResponse, '', OPTIONS)
const ownRows = records(page).filter((item) => id(item?.container?.id) === containerId)
context.assert(MODULE, '货柜页面列表返回唯一模拟柜和工作台动作状态', pageResponse,
ownRows.length === 1 && records(page).every((item) => containerWorkbenchGaps(item).length === 0)
&& ownRows[0]?.container?.cabinetNo === containerDetail?.cabinetNo
&& ownRows[0]?.container?.planStatus === containerDetail?.planStatus,
`matches=${ownRows.length}, rows=${records(page).length}`, OPTIONS)
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
@@ -475,7 +498,7 @@ export async function run(context) {
const activeContainersResponse = await context.request('GET',
`${DOMESTIC_CONTAINERS}?page=1&size=50&scope=ACTIVE&keyword=${encodeURIComponent(realContainerNo)}`)
const activeContainers = context.apiSuccess(MODULE, '关联后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item.containerNo === realContainerNo)
const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item?.containerNo === realContainerNo)
context.assert(MODULE, '关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份', activeContainersResponse,
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId,
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}`, OPTIONS)
@@ -592,7 +615,7 @@ export async function run(context) {
if (!taskId || !pickItem?.id) return
const releasedContainerResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
const releasedContainer = context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
const releasedContainer = containerRecord(context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS))
const stableIdentity = {
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
@@ -26,7 +26,8 @@ const matrix = read('../../../docs/国内货柜履约/货柜管理全链验收
test('container acceptance reads the canonical domestic workbench route', () => {
assert.match(containerLoadingAcceptance, /const DOMESTIC_CONTAINERS = `\$\{CONTAINERS\}\/domestic`/)
assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \|\| value \}/)
assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \}/)
assert.doesNotMatch(containerLoadingAcceptance, /value\?\.container \|\| value/)
assert.match(containerLoadingAcceptance, /DOMESTIC_CONTAINERS\}\?page=1&size=50&scope=ACTIVE&keyword=/)
assert.doesNotMatch(containerLoadingAcceptance, /request\('GET', `\$\{CONTAINERS\}\/\$\{containerId\}`\)/)
assert.match(phase3LoadingAcceptance, /\/api\/platform\/v1\/containers\/domestic\/\$\{containerBookingId\}/)
@@ -25,7 +25,7 @@ function failure(response) {
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
}
function containerRecord(value) { return value?.container || value }
function containerRecord(value) { return value?.container }
async function ensureMatsonSupplier(context, records) {
const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME)
@@ -79,7 +79,8 @@ test('container API uses one stable long string identity', () => {
assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/)
assert.match(controller, /convertContainer/)
assert.match(adminApi, /sourceContainerBookingId: string/)
assert.match(adminView, /sourceContainerBookingId: container\.sourceContainerBookingId/)
assert.match(adminView, /const bookingId = preview\.sourceContainerBookingId/)
assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: preview\.snapshotVersion,\s*snapshotHash: preview\.snapshotHash,/)
})
test('backend batch conversion is one transaction and never merges same-customer orders', () => {
+99 -23
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
@@ -126,13 +126,28 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目'
Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录'
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
Assert-Contains $testTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '测试环境全流程总控'
Assert-Contains $testTaskRegistry '`01a046ea-cfc4-73a1-85f0-c69c714bff22`' '测试环境海外接柜仓总控'
Assert-Contains $testTaskRegistry '| 海外接柜后端 |' '测试环境海外接柜后端专项'
Assert-Contains $testTaskRegistry '| 海外接柜Admin前端 |' '测试环境海外接柜 Admin 专项'
Assert-Contains $testTaskRegistry '| 海外接柜Client前端 |' '测试环境海外接柜 Client 专项'
Assert-Contains $testTaskRegistry '| 海外接柜PDA前端 |' '测试环境海外接柜 PDA 专项'
# 启用状态属于各环境当前登记事实;不得用测试分支的历史任务名称/ID强制重新启用本地清空表。
$registeredTestRows = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|'))
if ($registeredTestRows.Count -eq 0) {
Assert-Contains $testTaskRegistry '不启用总控调度' '测试登记清空状态'
Assert-Contains $testTaskRegistry '当前无已登记总控或长期专项' '测试登记清空明示'
Assert-Contains $testTaskRegistry '持续授权同步失效' '测试登记清空授权边界'
} else {
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
Assert-Contains $testTaskRegistry 'parent_task_id' '测试登记父任务字段'
Assert-Contains $testTaskRegistry 'project_id' '测试登记项目字段'
Assert-Matches $testTaskRegistry '(?m)^\|[^\r\n]*【总控】[^\r\n]*\|' '测试登记总控职责'
$registeredIds = @($registeredTestRows | ForEach-Object { $_.Groups[1].Value })
if (@($registeredIds | Select-Object -Unique).Count -ne $registeredIds.Count) {
Add-Failure '测试登记存在重复 task_id'
}
$taskLines = @($testTaskRegistry -split '\r?\n' | Where-Object { $_ -match '^\|\s*`[0-9a-f-]{36}`\s*\|' })
$controlLines = @($taskLines | Where-Object { $_.Contains('【总控】') })
if ($controlLines.Count -ne 1) { Add-Failure '测试登记必须有且仅有一个总控' }
foreach ($taskLine in $taskLines) {
Assert-Contains $taskLine '`c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试任务项目隔离'
}
}
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId'
@@ -157,13 +172,13 @@ foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三
Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围'
# 新增稳定业务能力必须从根表和目标部署单元直接命中。
Assert-Contains $rootAgents '入库扣货、解除与凭证' '根入库扣货路由'
Assert-Contains $rootAgents '入库单扣货、解除与履约门禁' '根入库扣货路由'
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由'
Assert-Contains $backendAgents '入库扣货、解除、履约门禁与凭证' '后端入库扣货路由'
Assert-Contains $rootAgents '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由'
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货路由'
Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户资金账户路由'
Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由'
Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结与结算' '后端客户资金账户路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户资金账户路由'
Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照'
Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照'
Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由'
@@ -172,7 +187,7 @@ Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根
Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由'
Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由'
Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由'
Assert-Contains $backendAgents '尾程订单控制与柜级入仓计划转换' '后端尾程订单控制路由'
Assert-Contains $backendAgents '尾程订单控制、柜级预览与入仓计划转换' '后端尾程订单控制路由'
$testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
$devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) {
@@ -263,6 +278,48 @@ Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBM
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先'
# 批量审核、客户专属价格簿和柜级阶梯计价已经成为稳定跨端能力,必须从根表直达且锁住服务端权威规则。
$batchReviewPreviewService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java')
$fbaPricingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/fba-pricing-v1.yaml')
$fbaPricingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java')
$inboundFeeDetailService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java')
$adminUnifiedOrdersApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts')
$adminCarrierRatesView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
Assert-Contains $rootAgents '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
Assert-Contains $adminAgents '订单列表、批量审核预览、详情、商品图片与入库费用' 'Admin 批量审核预览路由'
Assert-Contains $backendAgents '平台订单批量审核预览' '后端批量审核预览路由'
Assert-Contains $orderAgents '平台批量审核预览' 'Order 批量审核预览入口'
Assert-Contains $orderOpenApi '/api/platform/v1/orders/first-leg/batch-review-preview:' 'Order 批量审核预览 OpenAPI'
Assert-Contains $batchReviewPreviewService '固定两次批量查询生成审核弹窗快照' 'Order 批量审核禁止逐票查询'
Assert-Contains $adminUnifiedOrdersApi '/platform/v1/orders/first-leg/batch-review-preview' 'Admin 批量审核预览 API'
Assert-Contains $rootAgents '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
Assert-Contains $adminAgents '客户专属价格簿、柜级阶梯运费与受控调价' 'Admin 客户专属报价路由'
Assert-Contains $backendAgents '客户专属价格簿、柜级阶梯运费与受控调价' '后端客户专属报价路由'
Assert-Contains $firstlegAgents '客户专属价格簿、柜级阶梯运费与调价边界' 'Firstleg 客户专属报价入口'
Assert-Contains $fbaPricingOpenApi '计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本' '价格簿作用域优先级'
Assert-Contains $fbaPricingOpenApi '计费量只能在系统原计费量上下10%范围内(含边界)' '计费量调整边界'
Assert-Contains $fbaPricingService 'recalculateUnlockedContainerCustomerFees' '柜级阶梯费用重算入口'
Assert-Contains $firstlegAgents '一件代发 `WAIT_PALLETIZE` 可在预装确认前试算' 'Firstleg 一件代发预装前计价边界'
Assert-Contains $fbaPricingService '"WAIT_PALLETIZE"' '一件代发托盘化状态可计价'
Assert-Contains $fbaPricingService 'preserveProcessStatusDuringPricing' '计价保留履约状态入口'
Assert-Contains $inboundFeeDetailService 'PRIVATE_LAST_MILE_DELIVERY_FEE_CODE' '私人仓尾程派送费稳定代码'
Assert-Contains $adminCarrierRatesView 'customerId' 'Admin 价格簿客户作用域'
Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java'
# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。
$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java')
$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java')
Assert-Contains $rootAgents '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
Assert-Contains $adminAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
Assert-Contains $lastlegAgents '`RETURN_STOCK_IN` 流水' 'Lastleg 退回库存流水边界'
Assert-Contains $fileAgents 'businessType=OVERSEAS_EXCEPTION_EVIDENCE' 'File 海外异常证据归属'
Assert-Contains $exceptionOrderService 'restoreReturnedInventory' '海外异常退回库存恢复调用'
Assert-Contains $exceptionOrderService 'delivery.setStatus("RETURNED")' '海外异常完成派送状态'
Assert-Contains $inventoryCommandService 'ReturnedInventoryRestoreRequest' '海外退回库存命令入口'
Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java'
# 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。
$pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java')
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
@@ -391,7 +448,7 @@ Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI'
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI'
Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现'
Assert-Contains $terminalDrayageService 'context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现'
Assert-Contains $terminalDrayageService 'context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现'
Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费'
Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现'
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
@@ -675,28 +732,47 @@ Assert-Contains $productImageController "hasAuthority('platform:order:list')" '
Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览'
Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界'
# 尾程订单按来源柜展示服务端计划进度,整柜转换每票独立计划且整批原子。
# 尾程订单按来源柜展示服务端计划进度;整柜确认必须绑定预览快照并编排真实托盘批次。
$lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml')
$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
$adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
$containerConversionCoordinator = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java')
$warehousePalletBatchService = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java')
$overseasWarehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md')
$overseasPdaReceivingAdapter = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js')
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约'
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约'
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/container-preview:' 'Lastleg 整柜计划预览契约'
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
Assert-Contains $backendAgents '分页前排除全部入仓码均已生成有效独立计划的柜' '后端柜级计划待处理筛选'
Assert-Contains $adminAgents '返回仍有待生成计划入仓码的柜' 'Admin 柜级计划待处理筛选'
Assert-Contains $lastlegAgents '分页前排除全部入仓码均已生成有效独立计划的柜' 'Lastleg 柜级计划待处理筛选'
Assert-Contains $lastlegBusinessOpenApi '仅返回仍有入仓码待生成有效独立计划的柜' 'Lastleg 柜级计划待处理 OpenAPI'
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
Assert-Contains $adminAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
Assert-Contains $adminAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
Assert-Contains $adminAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'
Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressSummary' 'Lastleg 柜级计划进度摘要模型'
Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressDetail' 'Lastleg 柜级计划进度详情模型'
Assert-Contains $lastlegBusinessOpenApi 'allPlansReady' 'Lastleg 全柜计划就绪事实'
Assert-Contains $lastlegBusinessOpenApi 'plannedArrivalAt' 'Lastleg 计划到仓冻结事实'
Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA 权威仓校验入口'
Assert-Contains $lastlegAgents 'ContainerInboundPlanConversionCoordinator.java' 'Lastleg 柜级计划 Saga 入口'
Assert-Contains $containerConversionCoordinator 'warehouse.ensurePalletBatch' 'Lastleg 托盘批次预留'
Assert-Contains $containerConversionCoordinator 'warehouse.confirmPalletBatch' 'Lastleg 托盘批次确认'
Assert-Contains $containerConversionCoordinator 'warehouse.compensatePalletBatch' 'Lastleg 托盘批次补偿'
Assert-Contains $warehousePalletBatchService 'Only a RESERVED pallet batch can be confirmed' '海外资源批次确认状态门禁'
Assert-Contains $overseasWarehouseAgents '同柜重放不得产生第二批资源' '海外资源批次幂等边界'
Assert-Contains $overseasPdaAgents 'assigned/mergeAllowed/urgent/mergeHint' '海外 PDA 计划标志索引'
Assert-Contains $overseasPdaAgents '不在设备端重新分配' '海外 PDA 预留托盘边界'
Assert-Contains $overseasPdaReceivingAdapter 'mergeAllowed: value.mergeAllowed === true' '海外 PDA 合托可选事实适配'
# 国内 PDA 当前以页面、API 和作业状态合同覆盖部分拣货及库位不足反馈。
Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同'
@@ -723,8 +799,8 @@ Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'
Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界'
Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI'
Assert-Contains $containerFeeOpenApi '各分摊金额必须大于0且合计等于费用总额' '柜费分摊金额守恒'
Assert-Contains $containerFeeOpenApi '单行接口拒绝处理分摊组' '柜费分摊整组操作契约'
Assert-Contains $containerFeeOpenApi '各行按 HALF_UP 保留两位后必须大于零,落库金额合计必须等于两位小数的总额。' '柜费分摊金额守恒'
Assert-Contains $containerFeeOpenApi '分摊组不可经单行入口修改。' '柜费分摊整组操作契约'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口'
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
@@ -749,7 +825,7 @@ Assert-Contains $adminAgents '入库单扣货、解除、凭证与历史' 'Admin
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由'
Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口'
Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户钱包快速路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信与跨区资金指令' '后端客户钱包快速路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户钱包快速路由'
Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口'
Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口'
Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界'
@@ -1090,5 +1166,5 @@ if ($failures.Count -gt 0) {
exit 1
}
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
exit 0
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8')
const start = source.indexOf('$registeredTestRows =')
const end = source.indexOf("Assert-NotContains $testTaskRegistry '01a0194a", start)
assert.ok(start > 0 && end > start)
const registryContract = source.slice(start, end)
const project = 'c64729ff-6710-43c6-8ef2-d84e9666cc1e'
const root = '00000000-0000-4000-8000-000000000001'
const child = '00000000-0000-4000-8000-000000000002'
const enabled = `已重新启用测试环境总控调度\nparent_task_id project_id\n| \`${root}\` | \`${project}\` | 【总控】测试环境全流程 |\n| \`${child}\` | \`${root}\` | \`${project}\` | 【前端】专项 |`
function checkRegistry(text) {
const script = `
$ErrorActionPreference = 'Stop'
$failures = [System.Collections.Generic.List[string]]::new()
function Add-Failure([string]$message) { $script:failures.Add($message) }
function Assert-Contains([string]$text, [string]$needle, [string]$scope) { if (-not $text.Contains($needle)) { Add-Failure "$scope 缺少 $needle" } }
function Assert-Matches([string]$text, [string]$pattern, [string]$scope) { if ($text -notmatch $pattern) { Add-Failure "$scope 缺少结构" } }
$testTaskRegistry = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(text).toString('base64')}'))
${registryContract}
Write-Output ('RESULT:' + (ConvertTo-Json -InputObject @($failures) -Compress))
`
const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr)
const output = result.stdout.match(/RESULT:(\[[^\r\n]*\])/)
assert.ok(output, result.stdout)
return JSON.parse(output[1])
}
test('cleared test registry stays disabled and explicitly revokes inherited authorization', () => {
assert.deepEqual(checkRegistry('不启用总控调度\n当前无已登记总控或长期专项\n持续授权同步失效'), [])
assert.ok(checkRegistry('当前无已登记总控或长期专项').length > 0)
assert.ok(checkRegistry('').length > 0)
})
test('enabled registry requires explicit activation, one controller, unique tasks and its own project', () => {
assert.deepEqual(checkRegistry(enabled), [])
assert.ok(checkRegistry(enabled.replace('已重新启用测试环境总控调度', '')).length > 0)
assert.ok(checkRegistry(enabled.replace('parent_task_id', '')).length > 0)
assert.ok(checkRegistry(enabled.replace(child, root)).length > 0)
assert.ok(checkRegistry(enabled.replace('【前端】专项', '【总控】其他')).length > 0)
assert.ok(checkRegistry(enabled.replaceAll(project, '6f874ec3-d3b2-49bd-8625-327f4d685f98')).length > 0)
})
@@ -60,6 +60,7 @@ test('builds an order payload owned by the fixed profile', () => {
senderLocation: { province: '浙江省', city: '义乌市', district: '稠城街道', postalCode: '322000' },
},
productImageFileId: '33',
pickupAddressId: '55',
})
assert.equal(payload.receivingWarehouseId, '11')
assert.equal(payload.destinationWarehouseId, '22')
@@ -67,11 +68,14 @@ test('builds an order payload owned by the fixed profile', () => {
assert.equal(payload.customerName, '固定测试客户')
assert.equal(payload.cargoes.length, 1)
assert.equal(payload.cargoes[0].productImageFileId, '33')
assert.equal(payload.cargoes[0].woodenProduct, false)
assert.equal(payload.tradeTerm, 'EXW')
assert.equal(payload.pickupMethod, 'SELF_TRUCK')
assert.equal(payload.taxMode, 'TAX_REFUND')
assert.equal(payload.customsInspection, true)
assert.equal(payload.sender, null)
assert.equal(payload.senderAddressId, null)
assert.equal(payload.requestedPickupAt, null)
assert.equal(payload.cargoes[0].cargoName, PRODUCT_CATALOG[0].cargoName)
assert.equal(payload.cargoes[0].packageCount, 7)
assert.equal(payload.cargoes[0].totalQuantity, PRODUCT_CATALOG[0].unitsPerPackage * 7)
@@ -79,6 +83,27 @@ test('builds an order payload owned by the fixed profile', () => {
assert.equal(payload.cargoes[0].declaredValue, PRODUCT_CATALOG[0].unitDeclaredValue * PRODUCT_CATALOG[0].unitsPerPackage * 7)
})
test('uses the selected customer pickup address and appointment for pickup orders', () => {
const payload = buildOrderPayload({
marker: 'PENDING-PICKUP', index: 1,
profile: { id: '44', tenantName: '固定测试客户' },
master: {
receiving: { id: '11', warehouseName: '国内仓' },
destination: { id: '22', warehouseName: 'ONT8', warehouseCode: 'ONT8', countryCode: 'US' },
channel: { channelCode: 'SEA-US', channelName: '美西海运' }, pricingTaxMode: 'TAX_INCLUDED',
},
product: PRODUCT_CATALOG[0], packageCount: 1, productImageFileId: '33', pickupAddressId: '55',
options: {
tradeTerm: 'FOB', pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION',
customsInspection: false, expectedShipDays: 7,
senderLocation: { province: '广东省', city: '深圳市', district: '南山区', postalCode: '518000' },
},
})
assert.equal(payload.sender, null)
assert.equal(payload.senderAddressId, '55')
assert.match(payload.requestedPickupAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
})
test('randomizes only supported basic-order fields', () => {
for (let index = 0; index < 100; index += 1) {
const options = randomOrderOptions()
+40 -14
View File
@@ -247,7 +247,39 @@ async function uploadProductImage(gateway, timeoutMs, token, marker) {
return data.fileId
}
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, options = randomOrderOptions(), productImageFileId }) {
async function ensurePickupAddress(gateway, timeoutMs, token, profile, marker) {
const listResponse = await request(gateway, timeoutMs, 'GET',
'/api/merchant/v1/orders/first-leg/addresses?addressType=PICKUP', { token })
const existing = items(requireSuccess(listResponse, 'Query pickup addresses'))
.find((address) => address?.id)
if (existing) return String(existing.id)
const location = pickRandom(SENDER_LOCATIONS)
const createResponse = await request(gateway, timeoutMs, 'POST',
'/api/merchant/v1/orders/first-leg/addresses', {
token,
body: {
addressType: 'PICKUP',
addressName: '待审核订单测试取货地址',
companyName: profile.tenantName || `固定客户-${marker}`,
contactName: '订单测试联系人',
phone: '13800138000',
email: 'pending-order@example.com',
countryCode: 'CN',
province: location.province,
city: location.city,
district: location.district,
addressLine: `${marker} 固定客户测试地址`,
postalCode: location.postalCode,
defaultAddress: true,
},
})
const addressId = String(requireSuccess(createResponse, 'Create pickup address') ?? '')
if (!addressId) throw new Error('Create pickup address did not return address ID')
return addressId
}
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, options = randomOrderOptions(), productImageFileId, pickupAddressId }) {
const sequence = String(index).padStart(3, '0')
const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64)
const referenceId = `REF-${sequence}-${marker}`.slice(0, 64)
@@ -282,18 +314,10 @@ export function buildOrderPayload({ marker, index, profile, master, product, pac
amazonWarehouseCode: master.destination.warehouseCode,
shipmentId,
referenceId,
sender: options.pickupMethod === 'PICKUP' ? {
companyName,
contactName: '订单测试联系人',
phone: '13800138000',
email: 'pending-order@example.com',
countryCode: 'CN',
province: options.senderLocation.province,
city: options.senderLocation.city,
district: options.senderLocation.district,
addressLine: `${marker} 固定客户测试地址`,
postalCode: options.senderLocation.postalCode,
} : null,
requestedPickupAt: options.pickupMethod === 'PICKUP'
? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null,
senderAddressId: options.pickupMethod === 'PICKUP' ? pickupAddressId : null,
sender: null,
receiver: null,
cargoes: [{
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
@@ -325,6 +349,7 @@ export function buildOrderPayload({ marker, index, profile, master, product, pac
referenceId,
upcNo: `${Date.now()}${sequence}`.slice(-12),
productImageFileId,
woodenProduct: false,
dangerousGoods: false,
cargoRemark: `待审核订单货物 ${sequence} ${marker}`,
}],
@@ -383,6 +408,7 @@ export async function main(argv = process.argv.slice(2)) {
const routePool = await loadPricedRoutes(gateway, timeoutMs, token)
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
const pickupAddressId = await ensurePickupAddress(gateway, timeoutMs, token, profile, marker)
const randomizedProducts = shuffled(PRODUCT_CATALOG)
const sameChannel = args['same-channel'] === true
const randomizedRoutes = sameChannel ? selectSameChannelRoutes(routePool) : diversifyRoutes(routePool)
@@ -392,7 +418,7 @@ export async function main(argv = process.argv.slice(2)) {
const product = randomizedProducts[(index - 1) % randomizedProducts.length]
const packageCount = randomInt(1, 31)
const options = randomOrderOptions()
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId })
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId, pickupAddressId })
const createResponse = await request(gateway, timeoutMs, 'POST',
'/api/merchant/v1/orders/first-leg', {
token,
+1 -1
View File
@@ -11,7 +11,7 @@
"services": 15
},
"openapi": {
"documents": 43,
"documents": 45,
"coveredServices": 10,
"codeFirstServices": 5
},
+4 -1
View File
@@ -65,7 +65,10 @@ test('repository freezes the current topology and non-blocking aggregate summary
const summary = await buildQualitySummary(repositoryRoot)
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 })
assert.equal(summary.openapi.documents, 43)
assert.equal(summary.openapi.documents, 45)
assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml'))
assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml'))
assert.equal(baseline.openapi.documents, summary.openapi.documents)
assert.equal(summary.openapi.coveredServices.length, 10)
assert.equal(summary.openapi.codeFirstServices.length, 5)
assert.equal(baseline.mode, 'report-only')
@@ -157,6 +157,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '派送资料上传无幂等键,元数据按客户/派送单/类别校验,当前与历史下载只读'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '异常处理证据上传无幂等键,归属校验可重复'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java',
@@ -106,7 +106,7 @@ test('all 56 Feign clients have reviewed context, access and idempotency metadat
assert.deepEqual(await discoverFeignEdges(), reviewed)
})
test('all 20 raw HTTP transports have reviewed context, access and idempotency metadata', async () => {
test('all 21 raw HTTP transports have reviewed context, access and idempotency metadata', async () => {
const discovered = []
for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) {
for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) {
@@ -118,7 +118,7 @@ test('all 20 raw HTTP transports have reviewed context, access and idempotency m
}
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP')
.map(client => client.path).sort()
assert.equal(reviewed.length, 20)
assert.equal(reviewed.length, 21)
assert.deepEqual(discovered.sort(), reviewed)
})