优化
This commit is contained in:
@@ -267,6 +267,16 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc
|
||||
|
||||
## 仓库级入口清单
|
||||
|
||||
排车扩展入口还支持独立新批次 `--scenario reviews`,真实覆盖两级驳回重提、零扫码退回改货改车及旧版本/幂等冲突,再接完整履约。`--scenario review-terminal --resume-report <本扩展原失败报告> --resume-report-sha256 <原SHA>` 只接收已完成双签收、请求全部明确RESPONSE、审核包VERIFIED但顺序只读快照跨过异步完成版本的精确停点;新报告先只读核齐原单DELIVERED及全量库存,再仅结案释放本批资源,不重拣、重发、重签或覆盖旧失败。其他未知写和原人工单不接受此模式。
|
||||
|
||||
`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=<TEST配置>` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、独立费用/单据/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、费用、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-<run-id>/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。
|
||||
|
||||
`--scenario competition` 同样仅使用本轮新样本,验证占用中的车辆/司机拒绝第二张空排车、装车后尚未安排派送的发车拒绝,以及已成功装车原键的两个 Admin/PDA 请求并发重放;原操作员恢复唯一结果,另一操作员被拒绝,完整库存与主单事实不变。该场景不宣称首次有效命令竞争或完整权限矩阵通过。第二次签收确定成功后增加最多30秒只读完结等待,再进入既有跨接口一致性快照;不重放签收写入,也不修改既有失败报告或共享验证器。
|
||||
|
||||
`--scenario bol-empty|bol-same|bol-different --pdf-python <已安装pypdf的Python绝对路径>` 分别创建同目的仓无预约号、同号、不同号的两客户新样本。预约场景通过原有来源预约入口登记明确标记TEST的确认资料及1箱真实份额,再按返回的batchKey/version/来源归属关联原生排车;不借旧预约,不新增日历功能。实际BOL PDF、独立只读二进制请求账本和提取文字保存在该批 `evidence/`;核对跨客户合并/分组、各托只打印一次、总计2箱、打印前后完整库存不变,再继续正常履约结案。JSON请求计数不含独立二进制GET,二者分别留证;不把文字检查作为视觉验收。
|
||||
|
||||
BOL长托盘码按现行模板省略,自动核验使用完整入仓码与本轮一箱一托来源映射守恒,不声称PDF完整展示托盘码。若本轮在该显示断言处停止且尚未开始履约,可显式传原 `--resume-report` 和 `--resume-report-sha256` 到同一BOL场景、新runId;仅接受原READY夹具、精确已知错误、全部请求确定回执及无履约命令的冻结检查点。保留原失败,重新打印并核对当前状态后首次推进履约,不重建预约/排车,不允许未知写重试。
|
||||
|
||||
下表覆盖 `scripts/` 下所有非 `*.test.mjs` 的可执行入口。`*.test.mjs` 统一为离线 Node 静态合同,不访问测试环境;`admin-test-env-modules/*.mjs` 的逐模块说明由该目录 `README.md` 和总入口注册表维护。
|
||||
|
||||
| 分类 | 入口 | 用途与副作用 |
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。
|
||||
|
||||
其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。
|
||||
|
||||
其BOL扩展通过可选`shipmentReferences`和`prepareBatchAllocations`为本轮实收来源提供显式TEST货件参考及正式预约份额;分配准备后重新读取候选当前版本,默认夹具不创建预约。`bol-empty/same/different` 核验真实PDF页数、约号、逐托唯一出现及箱数守恒,打印前后原始库存和主单快照完全不变;实际二进制请求另存只读账本。
|
||||
|
||||
`procurement-sample-fulfillment.mjs` 是采购样品完整正常路径的内部验收助手,钱包综合模块也调用同一流程,并将采购请求证据独占保存于 `reports/procurement-wallet-checkpoints/<marker>.json`;客户会话在 finally 退出。由 `../procurement-sample-test-env.mjs` 注入本轮独立已充值客户、客户会话、有效收货仓、受控上传及权威钱包读取。创建新样本,执行正额货款、两次海外收货、两箱装箱、预估冻结、国际交寄、逐箱国内收货、实际核费及寄客户,逐阶段核对钱包现金/冻结和费用来源,关键请求原键重放。每次请求前必须 checkpoint;未知结果停写保留样本,不自动新键重试或清理。已接入显式 TEST 入口;未执行时不计作真实 HTTP 通过,也不覆盖异常、退款或权限全部矩阵。离线合同 `procurement-sample-fulfillment-contract.test.mjs` 验证前置缺失、余额不足、请求前持久化及未知结果停写。
|
||||
|
||||
`phase5-root-fulfillment` 可由总入口显式 `--prepare-domestic-pda-h5 true` 仅准备本轮国内H5待拣任务。调用 `phase3-order-splitting.loadOneFragment` 到正式计划确认、待拣任务及箱唛就绪即返回,不领取、不拣货、不继续海外;结果固定 `PREPARED_NOT_VERIFIED`,不算全链验收。默认完整链不变,范围及授权见根脚本README。
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyFleetCrossCustomer } from './fleet-cross-customer-acceptance.mjs'
|
||||
|
||||
const ROOT = '/api/platform/v1/last-leg', MODULE = 'dispatch-workflow-extensions'
|
||||
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
const clone = value => structuredClone(value)
|
||||
const id = value => String(value ?? '')
|
||||
export const extensionMatrix = Object.freeze([
|
||||
{ scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; distinct fee inputs and isolated execution documents', readiness: 'READY' },
|
||||
{ scenario: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' },
|
||||
{ scenario: 'relabel', scope: 'Confirmed relabel cancellation/rearrangement and multiple applications sharing one physical pallet', readiness: 'REQUIRES_FORMAL_RELABEL_FIXTURE' },
|
||||
{ scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources and unarranged departure gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' },
|
||||
{ scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' },
|
||||
])
|
||||
export function assertDualDestinationFixture(fixture) {
|
||||
assert.equal(fixture.members?.length, 2)
|
||||
assert.equal(new Set(fixture.members.map(row => id(row.customerId))).size, 2)
|
||||
assert.equal(new Set(fixture.members.map(row => row.destination?.key)).size, 2)
|
||||
assert(fixture.members.every(row => row.destination?.key && row.receiptVerified === true))
|
||||
assert(fixture.tripProof?.stops?.length >= 3)
|
||||
for (const member of fixture.members)
|
||||
assert(fixture.tripProof.stops.some(stop => stop.destinationKey === member.destination.key))
|
||||
}
|
||||
export function feeCents(value) {
|
||||
assert(['string', 'number'].includes(typeof value) && /^(0|[1-9]\d*)(\.\d{1,2})?$/.test(String(value)), 'Non-canonical fee amount')
|
||||
const [whole, fraction = ''] = String(value).split('.')
|
||||
const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'))
|
||||
assert(Number.isSafeInteger(cents)); return cents
|
||||
}
|
||||
export function applyIndependentFees(body, groups, members, marker) {
|
||||
assert.equal(groups?.length, 2); assert.equal(body.groups?.length, 2)
|
||||
assert.equal(new Set(groups.map(row => row.groupKey)).size, 2)
|
||||
assert.equal(new Set(groups.map(row => id(row.customerId))).size, 2)
|
||||
const centsByCustomer = new Map(members.map((member, index) => [member.customerId, index === 0 ? 3125 : 4775]))
|
||||
const expected = {}
|
||||
for (const group of body.groups) {
|
||||
const matches = groups.filter(row => row.groupKey === group.groupKey)
|
||||
assert.equal(matches.length, 1)
|
||||
const customerId = id(matches[0].customerId), cents = centsByCustomer.get(customerId)
|
||||
assert(cents && group.fees?.length > 0, 'Explicit current-customer fee services required')
|
||||
assert(!expected[customerId], 'Duplicated customer fee group')
|
||||
for (const fee of group.fees) { fee.amount = cents / 100; fee.feeName = `${marker} TEST合成客户独立费用` }
|
||||
expected[customerId] = group.fees.map(fee => ({ feeType: fee.feeType, currency: fee.currency, cents: feeCents(fee.amount) }))
|
||||
}
|
||||
return expected
|
||||
}
|
||||
|
||||
/** Public master-data reads only; no fixture writes until both independent US FBA masters are verified. */
|
||||
export async function discoverSecondDestination(context, primaryId) {
|
||||
const get = async path => {
|
||||
const response = await context.request('GET', path)
|
||||
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); return response.body.data
|
||||
}
|
||||
const primary = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(primaryId)}?tenantId=0`)
|
||||
const country = primary.addresses?.find(row => row.addressLine1)?.countryCode
|
||||
assert(country && primary.warehouse?.warehouseType === 'FBA' && primary.warehouse.status === 'ACTIVE')
|
||||
let total, scanned = 0
|
||||
const seen = new Set()
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const data = await get(`/api/platform/v1/warehouse-admin?${new URLSearchParams({ tenantId: '0', page: String(page), size: '200', warehouseType: 'FBA', status: 'ACTIVE', countryCode: country })}`)
|
||||
assert(Array.isArray(data.records)); assert.equal(Number(data.page), page); assert.equal(Number(data.size), 200)
|
||||
assert(Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0)
|
||||
total ??= Number(data.total); assert.equal(Number(data.total), total)
|
||||
assert(data.records.length > 0 || scanned === total)
|
||||
for (const row of data.records) {
|
||||
assert(row.id && !seen.has(id(row.id))); seen.add(id(row.id)); scanned++
|
||||
if (id(row.id) === id(primaryId)) continue
|
||||
const candidate = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(row.id)}?tenantId=0`)
|
||||
if (candidate.warehouse?.warehouseType !== 'FBA' || candidate.warehouse.status !== 'ACTIVE'
|
||||
|| id(candidate.warehouse.tenantId) !== '0' || candidate.warehouse.warehouseCode === primary.warehouse.warehouseCode) continue
|
||||
if (candidate.addresses?.some(address => address.addressLine1 && address.countryCode === country)) return id(row.id)
|
||||
}
|
||||
if (scanned === total) break
|
||||
}
|
||||
throw Error('No second distinct public active FBA destination with a real same-country address; no fixture created')
|
||||
}
|
||||
|
||||
/** Reuse the formal full-chain verifier; only explicitly supplied synthetic fee amounts differ by customer. */
|
||||
export async function verifyDualDestination(context, fixture, options) {
|
||||
if (!options.bol) assertDualDestinationFixture(fixture)
|
||||
else {
|
||||
assert.equal(fixture.members.length, 2); assert.equal(new Set(fixture.members.map(row => row.customerId)).size, 2)
|
||||
assert.equal(new Set(fixture.members.map(row => row.destination.key)).size, 1)
|
||||
assert(fixture.members.every(row => row.receiptVerified === true))
|
||||
}
|
||||
const proof = context.createdResources.dispatchWorkflowExtensions = { scenario: options.bol || 'dual-destination', marker: context.marker,
|
||||
dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false,
|
||||
hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] }
|
||||
const original = context.request
|
||||
let arrangement, deferredFailure, raceDone = false, unarrangedDone = false
|
||||
const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
|
||||
const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
|
||||
context.request = async function (method, path, body, ...rest) {
|
||||
if (deferredFailure) throw deferredFailure
|
||||
if (method === 'POST' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/transfer-delivery`) {
|
||||
proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker)
|
||||
proof.arrangeRequest = clone(body); await context.checkpoint()
|
||||
}
|
||||
const response = await original.call(this, method, path, body, ...rest)
|
||||
if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement`
|
||||
&& response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data)
|
||||
// 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。
|
||||
if (response.http === 200 && String(response.body?.code) === '200') {
|
||||
try {
|
||||
if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone
|
||||
&& response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') {
|
||||
raceDone = true
|
||||
await verifyLoadingReplayRace(context, fixture, options, original.bind(this), body, response.body.data)
|
||||
}
|
||||
if (options.competition && method === 'GET' && path === `${mainPath}/delivery-arrangement` && !unarrangedDone
|
||||
&& response.body.data?.stage === 'AFTER_LOADING' && response.body.data?.arrangement == null) {
|
||||
unarrangedDone = true
|
||||
await verifyUnarrangedDeparture(context, fixture, options, original.bind(this), response.body.data)
|
||||
}
|
||||
if (method === 'PUT' && body?.idempotencyKey === `${context.marker}-CROSS-SIGN-1`) {
|
||||
const fence = proof.completionReadFence = { status: 'READING', attempts: [], writeReceiptPreserved: true }
|
||||
for (let attempt = 1; attempt <= 60; attempt++) {
|
||||
const current = await original.call(this, 'GET', mainPath)
|
||||
assert.equal(current.http, 200); assert.equal(String(current.body?.code), '200')
|
||||
assert.equal(current.body.data?.dispatchNo, fixture.dispatchNo)
|
||||
fence.attempts.push({ attempt, stage: current.body.data.stage, recordVersion: current.body.data.recordVersion,
|
||||
requestId: current.requestId, traceId: current.traceId })
|
||||
if (current.body.data.stage === 'DELIVERED') { fence.status = 'DELIVERED'; break }
|
||||
assert.equal(current.body.data.stage, 'DELIVERING')
|
||||
await context.checkpoint(); await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
assert.equal(fence.status, 'DELIVERED', 'Automatic completion did not settle within 30 seconds')
|
||||
await context.checkpoint()
|
||||
}
|
||||
} catch (error) { deferredFailure = error }
|
||||
}
|
||||
return response
|
||||
}
|
||||
let verified
|
||||
try { verified = await verifyFleetCrossCustomer(context, fixture, options) }
|
||||
finally { context.request = original }
|
||||
assert.equal(verified.status, 'VERIFIED')
|
||||
const final = verified.snapshots.FINAL
|
||||
assert.equal(new Set(final.dispatch.documents.deliveries.map(row => row.deliveryNo)).size, 2)
|
||||
assert.equal(new Set(final.dispatch.documents.outbounds.map(row => row.outboundNo)).size, 2)
|
||||
const feeIds = new Set()
|
||||
for (const [index, member] of fixture.members.entries()) {
|
||||
const delivery = final.members[index].delivery
|
||||
assert.equal(id(delivery.order.customerId), member.customerId)
|
||||
assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode)
|
||||
assert.equal(Number(delivery.order.packageCount), 1); assert.equal(Number(delivery.order.palletCount), 1)
|
||||
const expected = proof.expectedFees[member.customerId]
|
||||
assert(expected?.length > 0); assert.equal(delivery.fees?.length, expected.length)
|
||||
for (const fee of delivery.fees) {
|
||||
assert(fee.id && !feeIds.has(id(fee.id))); feeIds.add(id(fee.id))
|
||||
assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount)))
|
||||
assert.equal(fee.feeDirection, 'RECEIVABLE'); assert.equal(fee.sourceStatus, 'ACTIVE')
|
||||
}
|
||||
const other = fixture.members[1 - index]
|
||||
const path = `${ROOT}/delivery-orders/${encodeURIComponent(delivery.order.deliveryNo)}?customerId=${encodeURIComponent(other.customerId)}`
|
||||
const response = await context.request('GET', path)
|
||||
const denied = [200, 403, 404].includes(response.http) && ['A0403', 'A0404'].includes(String(response.body?.code))
|
||||
context.assert(MODULE, 'Foreign customer cannot read this delivery or its independent fees', response, denied, '', OPTIONS)
|
||||
assert(denied)
|
||||
proof.isolation.push({ customerId: member.customerId, wrongCustomerId: other.customerId, deliveryNo: delivery.order.deliveryNo,
|
||||
http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId })
|
||||
}
|
||||
assert.notDeepEqual(proof.expectedFees[fixture.members[0].customerId], proof.expectedFees[fixture.members[1].customerId])
|
||||
proof.status = 'VERIFIED'; await context.checkpoint(); return verified
|
||||
}
|
||||
|
||||
const receipt = response => ({ http: response.http, code: response.body?.code, message: response.body?.message,
|
||||
requestId: response.requestId, traceId: response.traceId })
|
||||
async function competitionSnapshot(context, fixture, { readInventory }, send) {
|
||||
const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
|
||||
const state = {}
|
||||
for (const [name, target] of [['main', path], ['items', `${path}/items`], ['loading', `${path}/loading`],
|
||||
['trip', `${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`]]) {
|
||||
const response = await send('GET', target)
|
||||
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); state[name] = response.body.data
|
||||
}
|
||||
state.stock = []
|
||||
for (const member of fixture.members) state.stock.push(await readInventory({ marker: context.marker,
|
||||
customerId: member.customerId, warehouseId: fixture.warehouseId }))
|
||||
return state
|
||||
}
|
||||
|
||||
/** 仅两个请求同时发送;使用已成功装车的原键,明确区别首次执行竞争。 */
|
||||
export async function verifyLoadingReplayRace(context, fixture, options, send, body, acknowledged) {
|
||||
const proof = context.createdResources.dispatchWorkflowCompetition
|
||||
assert(proof && proof.status === 'CHECKING')
|
||||
const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete`
|
||||
const pda = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete`
|
||||
const race = proof.loadingReplayRace = { status: 'INTENT', scope: 'ACKNOWLEDGED_COMMAND_TWO_ACTOR_REPLAY',
|
||||
firstExecutionRace: 'NOT_TESTED', body: clone(body), before: await competitionSnapshot(context, fixture, options, send) }
|
||||
await context.checkpoint()
|
||||
const results = await Promise.allSettled([send('POST', pda, clone(body), fixture.pdaToken), send('POST', path, clone(body))])
|
||||
race.responses = results.map(result => result.status === 'fulfilled' ? receipt(result.value) : { outcome: 'UNKNOWN_RESULT' })
|
||||
race.status = results.every(result => result.status === 'fulfilled') ? 'RESPONDED' : 'UNKNOWN_RESULT'
|
||||
await context.checkpoint()
|
||||
for (const result of results) if (result.status === 'rejected') throw result.reason
|
||||
const [pdaResult, adminResult] = results.map(result => result.value)
|
||||
assert.equal(String(pdaResult.body?.code), '200'); assert.equal(pdaResult.body?.data?.replayed, true)
|
||||
assert.deepEqual(pdaResult.body.data.result, acknowledged.result)
|
||||
assert.equal(id(pdaResult.body.data.recordVersion), id(acknowledged.recordVersion))
|
||||
assert.equal(String(adminResult.body?.code), 'A0403'); assert.match(adminResult.body?.message || '', /操作员/)
|
||||
race.after = await competitionSnapshot(context, fixture, options, send)
|
||||
assert.deepEqual(race.after, race.before)
|
||||
context.assert(MODULE, 'Concurrent Admin/PDA original-key replays preserve the one loading result and complete inventories',
|
||||
pdaResult, true, '', OPTIONS)
|
||||
race.status = 'VERIFIED'; await context.checkpoint()
|
||||
}
|
||||
|
||||
async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) {
|
||||
const proof = context.createdResources.dispatchWorkflowCompetition
|
||||
const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send),
|
||||
body: { recordVersion: arrangement.recordVersion, idempotencyKey: `${context.marker}-EXT-NO-ARRANGEMENT-DEPART`, remark: 'TEST装车后未安排派送必须拒绝' } }
|
||||
assert.equal(state.before.main.stage, 'AFTER_LOADING'); assert.equal(state.before.main.documents?.deliveries?.length, 0)
|
||||
await context.checkpoint()
|
||||
const response = await send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/depart`, state.body, fixture.pdaToken)
|
||||
state.response = receipt(response); await context.checkpoint()
|
||||
assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /派送|安排/)
|
||||
state.after = await competitionSnapshot(context, fixture, options, send); assert.deepEqual(state.after, state.before)
|
||||
context.assert(MODULE, 'Loaded but unarranged departure is rejected without inventory or execution mutation', response, true, '', OPTIONS)
|
||||
state.status = 'VERIFIED'; await context.checkpoint()
|
||||
}
|
||||
|
||||
export async function verifyCompetitionExtension(context, fixture, options) {
|
||||
const proof = context.createdResources.dispatchWorkflowCompetition = { marker: context.marker, dispatchNo: fixture.dispatchNo,
|
||||
status: 'CHECKING', firstExecutionRace: 'NOT_TESTED', arbitraryPrivilegeMatrix: 'NOT_TESTED_NO_PERMISSION_MUTATION' }
|
||||
const before = await competitionSnapshot(context, fixture, options, context.request.bind(context))
|
||||
const body = { idempotencyKey: `${context.marker}-EXT-OCCUPIED-RESOURCE`, warehouseId: fixture.warehouseId,
|
||||
vehicle: { mode: 'MASTER', vehicleId: fixture.tripProof.vehicleId },
|
||||
driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } },
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null,
|
||||
remark: `${context.marker} TEST已占用资源不能分配另一空车`, cargo: [], batchAllocations: [] }
|
||||
proof.resourceConflict = { status: 'INTENT', body: clone(body), before }; await context.checkpoint()
|
||||
const response = await context.request('POST', `${ROOT}/dispatches`, body)
|
||||
proof.resourceConflict.response = receipt(response); await context.checkpoint()
|
||||
assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /车辆.*占用|司机.*占用/)
|
||||
proof.resourceConflict.after = await competitionSnapshot(context, fixture, options, context.request.bind(context))
|
||||
assert.deepEqual(proof.resourceConflict.after, before)
|
||||
context.assert(MODULE, 'Occupied vehicle/driver reject a second native main without changing the existing trip or inventory', response, true, '', OPTIONS)
|
||||
proof.resourceConflict.status = 'VERIFIED'
|
||||
const verified = await verifyDualDestination(context, fixture, { ...options, competition: true })
|
||||
assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED')
|
||||
proof.status = 'VERIFIED_BOUNDED_SCOPE'; await context.checkpoint(); return verified
|
||||
}
|
||||
|
||||
/** 仅使用本轮PDA实收的一箱来源,正式创建预约并向原生建单返回实际份额。 */
|
||||
export async function prepareBolAppointments(context, members, scenario, warehouseId) {
|
||||
assert(['bol-empty', 'bol-same', 'bol-different'].includes(scenario))
|
||||
const proof = context.createdResources.dispatchWorkflowBolAppointments = { scenario, status: 'PREPARING', appointments: [], allocations: [] }
|
||||
if (scenario === 'bol-empty') { proof.status = 'NO_APPOINTMENT'; return [] }
|
||||
const groups = scenario === 'bol-same' ? [members] : members.map(member => [member])
|
||||
for (const [index, group] of groups.entries()) {
|
||||
for (const member of group) {
|
||||
const response = await context.request('GET', `${ROOT}/warehouse-transfers/${encodeURIComponent(member.incomingNo)}?customerId=${encodeURIComponent(member.customerId)}`)
|
||||
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200')
|
||||
const transfer = response.body.data
|
||||
assert.equal(transfer.transferNo, member.incomingNo); assert.equal(transfer.inboundCode, member.incomingNo)
|
||||
assert.equal(id(transfer.inboundOrderId), id(member.receipt.balance.inboundOrderId))
|
||||
assert.equal(id(transfer.operatingWarehouseId), warehouseId)
|
||||
assert(transfer.lastlegOrderId && member.selection.planPalletId && member.receiptVerified)
|
||||
member.appointmentSourceKey = `ORDER:${transfer.lastlegOrderId}`
|
||||
}
|
||||
const refs = group.map(member => {
|
||||
return { customerId: member.customerId, sourceKey: member.appointmentSourceKey, destinationKey: member.destination.key }
|
||||
})
|
||||
const confirmationNo = `TEST-ISA-${context.marker.slice(-12)}-${index + 1}`
|
||||
const body = { sources: refs, originWarehouseId: warehouseId, fbaWarehouseCode: group[0].destination.warehouseCode,
|
||||
details: { appointmentStatus: 'SUCCESS', confirmationNo, appointmentAt: new Date(Date.now() + 48 * 3600000).toISOString().slice(0, 19),
|
||||
appointmentEndAt: new Date(Date.now() + 50 * 3600000).toISOString().slice(0, 19), appointmentRemark: `${context.marker} TEST合成预约确认资料` },
|
||||
idempotencyKey: `${context.marker}-BOL-AP-${index + 1}` }
|
||||
const record = { status: 'INTENT', body: clone(body) }; proof.appointments.push(record); await context.checkpoint()
|
||||
const response = await context.request('POST', `${ROOT}/appointment-planning/appointments`, body)
|
||||
record.response = receipt(response); record.status = 'RESPONDED'; await context.checkpoint()
|
||||
const data = context.apiSuccess(MODULE, 'Create own formal FBA appointment for exact received quantities', response, '', OPTIONS)
|
||||
assert.equal(String(response.body?.code), '200'); assert(data?.batchKey && data.appointmentNo)
|
||||
assert.equal(data.confirmationNo, confirmationNo); assert.equal(data.appointmentStatus, 'SUCCESS')
|
||||
assert.equal(data.items.length, group.length); assert(Number.isSafeInteger(data.version) && data.version >= 0)
|
||||
record.data = clone(data)
|
||||
for (const member of group) {
|
||||
const matches = data.items.filter(item => id(item.customerId) === member.customerId && item.sourceKey === member.appointmentSourceKey)
|
||||
assert.equal(matches.length, 1); assert.equal(Number(matches[0].packageCount), 1)
|
||||
assert.equal(matches[0].destinationKey, member.destination.key)
|
||||
proof.allocations.push({ planPalletId: member.selection.planPalletId, batchKey: data.batchKey, customerId: member.customerId,
|
||||
sourceKey: matches[0].sourceKey, destinationKey: matches[0].destinationKey, packageCount: 1, recordVersion: data.version })
|
||||
}
|
||||
record.status = 'VERIFIED'; await context.checkpoint()
|
||||
}
|
||||
proof.status = 'VERIFIED'; return clone(proof.allocations)
|
||||
}
|
||||
|
||||
export function assertBolPages(pages, fixture, appointments, scenario) {
|
||||
const expected = scenario === 'bol-different' ? 2 : 1
|
||||
assert.equal(pages.length, expected)
|
||||
const counts = [], isas = [], flattened = pages.map(page => page.replace(/\s/g, ''))
|
||||
for (const page of pages) {
|
||||
assert.match(page, /BILL OF LADING/)
|
||||
const total = page.match(/Total\s+CTNS:\s*(\d+)/)
|
||||
assert(total, 'Printed total box count missing'); counts.push(Number(total[1]))
|
||||
const isaLine = page.split(/\r?\n/).find(line => line.includes('ISA #:'))
|
||||
assert(isaLine !== undefined); isas.push(isaLine.split('ISA #:')[1].trim())
|
||||
}
|
||||
assert.equal(counts.reduce((sum, count) => sum + count, 0), 2)
|
||||
// 现行窄托盘列会省略长托盘码;用完整入仓码核对原实收来源,每个来源在本夹具中严格对应一箱一实托。
|
||||
for (const member of fixture.members) {
|
||||
assert(member.incomingNo && member.palletNo)
|
||||
assert.equal(flattened.reduce((sum, page) => sum + page.split(member.incomingNo).length - 1, 0), 1)
|
||||
}
|
||||
const expectedIsas = appointments.appointments.map(row => row.data.confirmationNo)
|
||||
assert.deepEqual([...isas].sort(), scenario === 'bol-empty' ? [''] : [...expectedIsas].sort())
|
||||
if (scenario !== 'bol-different') assert.deepEqual(counts, [2])
|
||||
else assert.deepEqual(counts, [1, 1])
|
||||
return { pageCount: pages.length, totalPackages: 2, packagesPerBol: counts, confirmationNos: isas,
|
||||
eachSourcePrintedOnce: true, physicalPalletMapping: 'ONE_BOX_ONE_PALLET_PER_SOURCE', palletColumn: 'LONG_CODES_TRUNCATED_BY_EXISTING_TEMPLATE' }
|
||||
}
|
||||
|
||||
export async function verifyBolExtension(context, fixture, options) {
|
||||
assert(typeof options.inspectBol === 'function')
|
||||
const proof = context.createdResources.dispatchWorkflowBol = { scenario: options.bol, status: 'CHECKING',
|
||||
physicalMeasurementVerified: false, hardwareVerified: false }
|
||||
proof.before = await competitionSnapshot(context, fixture, options, context.request.bind(context))
|
||||
const pages = await options.inspectBol(context, fixture)
|
||||
proof.printed = assertBolPages(pages, fixture, context.createdResources.dispatchWorkflowBolAppointments, options.bol)
|
||||
proof.after = await competitionSnapshot(context, fixture, options, context.request.bind(context))
|
||||
assert.deepEqual(proof.after, proof.before)
|
||||
context.assert(MODULE, 'Actual BOL PDF preserves appointment shares, cross-customer grouping and complete inventory facts',
|
||||
{ http: 200 }, true, '', OPTIONS)
|
||||
proof.status = 'VERIFIED'; await context.checkpoint()
|
||||
return verifyDualDestination(context, fixture, options)
|
||||
}
|
||||
|
||||
/** Exercise edits only on this fresh zero-picked main, then leave a new fully approved round for the formal chain. */
|
||||
export async function exerciseReviewLifecycle(context, fixture, { readInventory }) {
|
||||
const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
|
||||
const proof = context.createdResources.dispatchWorkflowReviews = { marker: context.marker, dispatchNo: fixture.dispatchNo,
|
||||
status: 'CHECKING', commands: [], rejections: [], snapshots: {}, syntheticTestMaterials: true }
|
||||
const request = async (method, target, body, label, reject = false) => {
|
||||
if (method !== 'GET') { proof.commands.push({ method, path: target, body: clone(body) }); await context.checkpoint() }
|
||||
const response = await context.request(method, target, body)
|
||||
if (reject) {
|
||||
const denied = [200, 409].includes(response.http) && ['A0409', '409'].includes(String(response.body?.code))
|
||||
context.assert(MODULE, label, response, denied, '', OPTIONS); assert(denied)
|
||||
proof.rejections.push({ label, http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId })
|
||||
return response
|
||||
}
|
||||
const data = context.apiSuccess(MODULE, label, response, '', OPTIONS)
|
||||
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data
|
||||
}
|
||||
const get = (target, label) => request('GET', target, undefined, label)
|
||||
const snapshot = async label => {
|
||||
const data = { main: await get(path, label), items: await get(`${path}/items`, label), route: await get(`${path}/route`, label), stock: [] }
|
||||
for (const member of fixture.members) data.stock.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId }))
|
||||
proof.snapshots[label] = data; await context.checkpoint(); return data
|
||||
}
|
||||
const initial = await snapshot('INITIAL_APPROVED_ZERO_PICK')
|
||||
assert.equal(initial.main.stage, 'LOADING'); assert.equal(initial.main.reviewState, 'APPROVED')
|
||||
assert.equal(Number(initial.main.counts.pickedPackageCount), 0); assert.equal(Number(initial.main.counts.shippedPackageCount), 0)
|
||||
const key = label => `${context.marker}-REV-${label}`
|
||||
let result = await request('POST', `${path}/return-to-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('RETURN'), reason: 'TEST零扫码显式退回改货改车' }, 'Zero-pick explicit return')
|
||||
assert.equal(result.stage, 'BEFORE_LOADING')
|
||||
let before = await snapshot('RETURNED')
|
||||
assert.equal(before.main.reviewState, 'DRAFT')
|
||||
await request('POST', `${path}/submit-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('STALE'), remark: context.marker }, 'Stale main version is rejected', true)
|
||||
assert.deepEqual(await snapshot('AFTER_STALE'), before)
|
||||
const submit = { recordVersion: before.main.recordVersion, idempotencyKey: key('SUBMIT-REJECT-ROUND'), remark: 'TEST第一重提轮次' }
|
||||
result = await request('POST', `${path}/submit-review`, submit, 'Submit dispatch rejection round')
|
||||
before = await snapshot('SUBMITTED')
|
||||
const replay = await request('POST', `${path}/submit-review`, submit, 'Original submit key replays')
|
||||
assert.equal(replay.replayed, true); assert.equal(String(replay.recordVersion), String(result.recordVersion))
|
||||
await request('POST', `${path}/submit-review`, { ...submit, remark: 'TEST同键异载荷不得复用' }, 'Changed payload with original key is rejected', true)
|
||||
await request('POST', `${path}/submit-review`, { ...submit, recordVersion: before.main.recordVersion, idempotencyKey: key('DUPLICATE-SUBMIT') }, 'New-key duplicate submit is rejected', true)
|
||||
assert.deepEqual(await snapshot('AFTER_DUPLICATE_REJECTIONS'), before)
|
||||
result = await request('POST', `${path}/dispatch-review`, { recordVersion: before.main.recordVersion, idempotencyKey: key('DISPATCH-REJECT'), decision: 'REJECTED', reason: 'TEST调度驳回' }, 'Dispatch reviewer rejects')
|
||||
let detail = await get(path, 'Read dispatch rejection')
|
||||
assert.equal(detail.reviewState, 'DISPATCH_REJECTED'); assert.equal(detail.reviews.dispatchReview.status, 'REJECTED')
|
||||
result = await request('POST', `${path}/submit-review`, { recordVersion: detail.recordVersion, idempotencyKey: key('SUBMIT-LOADING-REJECT'), remark: 'TEST驳回重提' }, 'Resubmit after dispatch rejection')
|
||||
result = await request('POST', `${path}/dispatch-review`, { recordVersion: result.recordVersion, idempotencyKey: key('DISPATCH-APPROVE'), decision: 'APPROVED', reason: 'TEST调度通过' }, 'Dispatch approves new round')
|
||||
result = await request('POST', `${path}/loading-review`, { recordVersion: result.recordVersion, idempotencyKey: key('LOADING-REJECT'), decision: 'REJECTED', reason: 'TEST理货驳回' }, 'Loading reviewer rejects')
|
||||
detail = await get(path, 'Read loading rejection')
|
||||
assert.equal(detail.reviewState, 'LOADING_REJECTED'); assert.equal(detail.reviews.loadingReview.status, 'REJECTED')
|
||||
const second = fixture.members[1]
|
||||
result = await request('POST', `${path}/items/${encodeURIComponent(second.dispatchItemId)}/remove`, {
|
||||
recordVersion: detail.recordVersion, idempotencyKey: key('REMOVE-SECOND'), reason: 'TEST零扫码先移出一票再真实重选' }, 'Remove only unpicked second member')
|
||||
const removed = await snapshot('SECOND_REMOVED')
|
||||
assert.equal(Number(removed.main.counts.packageCount), 1); assert.equal(removed.items.items.length, 1)
|
||||
assert(removed.route.stops.every(stop => stop.destinationKey !== second.destination.key))
|
||||
const candidates = await get(`${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, orderNo: second.incomingNo })}`, 'Read freshly released source candidate')
|
||||
const choices = candidates.candidates?.filter(row => id(row.customerId) === second.customerId && id(row.selection?.warehousePalletId) === second.palletId && row.palletNo === second.palletNo)
|
||||
assert.equal(choices?.length, 1); const choice = choices[0]
|
||||
assert(choice.selection && choice.sourceVersion && choice.blockReasons?.length === 0 && Number(choice.availablePackages) === 1)
|
||||
result = await request('POST', `${path}/items`, { recordVersion: result.recordVersion, idempotencyKey: key('ADD-SECOND'),
|
||||
cargo: [{ ...choice.selection, warehousePalletId: second.palletId, sourceVersion: choice.sourceVersion }], batchAllocations: [] }, 'Re-add exact released current source')
|
||||
const added = await snapshot('SECOND_RESTORED')
|
||||
assert.equal(added.items.items.length, 2); assert.equal(Number(added.main.counts.packageCount), 2)
|
||||
const replacement = added.items.items.filter(row => id(row.customerId) === second.customerId && id(row.warehousePalletId) === second.palletId)
|
||||
assert.equal(replacement.length, 1); second.dispatchItemId = id(replacement[0].dispatchItemId)
|
||||
const nextPlate = `FT-${context.marker.slice(-12)}-R2`
|
||||
const vehicle = await request('POST', `${ROOT}/master-data/vehicles`, { plateNumber: nextPlate, model: 'Box Truck', fuelConsumptionPer100Km: 12.5,
|
||||
mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
|
||||
remark: `${context.marker} TEST零扫码改派自有车辆` }, 'Create owned replacement TEST vehicle')
|
||||
const update = { recordVersion: result.recordVersion, idempotencyKey: key('CHANGE-VEHICLE'), vehicle: { mode: 'MASTER', vehicleId: id(vehicle.id) },
|
||||
driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } },
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, remark: `${context.marker} TEST零扫码改车需两级重审` }
|
||||
result = await request('PUT', path, update, 'Change resources before re-review')
|
||||
detail = await get(path, 'Resource edit invalidates old approvals')
|
||||
assert.equal(detail.reviewState, 'DRAFT'); assert.equal(id(detail.vehicleInfo.vehicle.vehicleId), id(vehicle.id))
|
||||
assert.notEqual(id(vehicle.id), fixture.tripProof.vehicleId)
|
||||
for (const [action, suffix] of [['submit-review', 'FINAL-SUBMIT'], ['dispatch-review', 'FINAL-DISPATCH'], ['loading-review', 'FINAL-LOADING']]) {
|
||||
result = await request('POST', `${path}/${action}`, { recordVersion: result.recordVersion, idempotencyKey: key(suffix),
|
||||
...(action === 'submit-review' ? { remark: 'TEST变更后重审' } : { decision: 'APPROVED', reason: 'TEST新依据重审通过' }) }, `Final new round ${action}`)
|
||||
}
|
||||
const final = await snapshot('REAPPROVED_ZERO_PICK')
|
||||
assert.equal(final.main.stage, 'LOADING'); assert.equal(final.main.reviewState, 'APPROVED')
|
||||
assert(Number(final.main.reviews.dispatchReview.reviewRound) > Number(initial.main.reviews.dispatchReview.reviewRound))
|
||||
assert.equal(final.main.reviews.dispatchReview.reviewRound, final.main.reviews.loadingReview.reviewRound)
|
||||
assert.deepEqual(final.stock, initial.stock, 'All zero-pick review and cargo/resource edits preserve complete inventory')
|
||||
const fleet = await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`, 'Read same main updated trip resources and route')
|
||||
assert.equal(fleet.trip.tripNo, fixture.tripNo); assert.equal(id(fleet.trip.vehicleId), id(vehicle.id))
|
||||
assert.equal(fleet.dispatches.length, 1); assert.equal(fleet.dispatches[0].dispatchNo, fixture.dispatchNo)
|
||||
Object.assign(fixture.tripProof, { marker: nextPlate, vehicleId: id(vehicle.id), vehicle: final.main.vehicleInfo.vehicle,
|
||||
vehicleInfo: final.main.vehicleInfo, driver: final.main.vehicleInfo.driver, updateRequest: update, trip: fleet.trip,
|
||||
stops: fleet.trip.stops, fleetRecordVersion: fleet.trip.recordVersion, recordVersion: final.main.recordVersion })
|
||||
proof.membersAfterEdit = clone(fixture.members); proof.finalVehicleInfo = clone(final.main.vehicleInfo)
|
||||
proof.status = 'VERIFIED'; await context.checkpoint(); return proof
|
||||
}
|
||||
|
||||
export async function verifyReviewExtension(context, fixture, options) {
|
||||
await exerciseReviewLifecycle(context, fixture, options)
|
||||
return verifyDualDestination(context, fixture, options)
|
||||
}
|
||||
|
||||
/** Read-only terminal reconciliation after the known sequential-read completion race; never replay fulfillment. */
|
||||
export async function reconcileReviewTerminal(context, fixture, { readInventory }, prior) {
|
||||
assert.equal(prior.success, false)
|
||||
assert.equal(prior.error, 'Native main/items/PDA retain both true customers and physical pallets')
|
||||
assert.equal(prior.createdResources.dispatchWorkflowReviews.status, 'VERIFIED')
|
||||
assert.equal(prior.createdResources.fleetWriteBoundary, undefined)
|
||||
const old = prior.createdResources.fleetCrossCustomer, path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
|
||||
assert.equal(old.dispatchNo, fixture.dispatchNo); assert.equal(old.marker, context.marker)
|
||||
assert(old.requests.filter(row => row.label === 'Attach receipt to independently complete this delivery').length === 2)
|
||||
assert(old.requests.every(row => row.status === 'RESPONDED'))
|
||||
const get = async target => {
|
||||
const response = await context.request('GET', target)
|
||||
const data = context.apiSuccess(MODULE, 'Explicit terminal reconciliation read only', response, '', OPTIONS)
|
||||
assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data
|
||||
}
|
||||
const current = { trip: await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`), dispatch: await get(path),
|
||||
items: await get(`${path}/items`), task: null, members: [] }
|
||||
const pda = await context.request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`, undefined, fixture.pdaToken)
|
||||
assert.equal(pda.http, 200); assert.equal(String(pda.body?.code), '200'); current.task = pda.body.data
|
||||
assert.equal(current.dispatch.stage, 'DELIVERED'); assert.equal(current.dispatch.status, 'ACTIVE')
|
||||
for (const value of [current.items, current.task]) {
|
||||
assert.equal(value.dispatchNo, fixture.dispatchNo); assert.equal(value.stage, 'DELIVERED')
|
||||
assert.equal(id(value.recordVersion), id(current.dispatch.recordVersion)); assert.equal(value.items.length, 2)
|
||||
}
|
||||
for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) assert.equal(Number(current.dispatch.counts[field]), 2)
|
||||
assert.equal(current.trip.trip.tripNo, fixture.tripNo)
|
||||
assert.equal(id(current.trip.trip.vehicleId), fixture.tripProof.vehicleId); assert.equal(id(current.trip.trip.driverId), fixture.tripProof.driverId)
|
||||
assert.equal(current.trip.dispatches.length, 1); assert.equal(current.trip.dispatches[0].dispatchNo, fixture.dispatchNo)
|
||||
const extension = context.createdResources.dispatchWorkflowExtensions = clone(prior.createdResources.dispatchWorkflowExtensions)
|
||||
extension.status = 'CHECKING'; extension.explicitTerminalContinuation = true; extension.priorFailurePreserved = true
|
||||
const allFeeIds = new Set()
|
||||
for (const [index, member] of fixture.members.entries()) {
|
||||
const oldMember = old.members[index]
|
||||
assert.equal(member.customerId, oldMember.customerId); assert.equal(member.dispatchItemId, oldMember.dispatchItemId)
|
||||
for (const rows of [current.items.items, current.task.items]) {
|
||||
const matches = rows.filter(row => id(row.dispatchItemId) === member.dispatchItemId)
|
||||
assert.equal(matches.length, 1); assert.equal(id(matches[0].customerId), member.customerId)
|
||||
assert.equal(matches[0].palletNo, member.palletNo); assert.equal(Number(matches[0].pickedPackages), 1)
|
||||
}
|
||||
const suffix = new URLSearchParams({ customerId: member.customerId })
|
||||
const outbound = await get(`${ROOT}/outbound-orders/${encodeURIComponent(oldMember.outboundNo)}?${suffix}`)
|
||||
const delivery = await get(`${ROOT}/delivery-orders/${encodeURIComponent(oldMember.deliveryNo)}?${suffix}`)
|
||||
assert.equal(outbound.outbound.dispatchNo, fixture.dispatchNo); assert.equal(outbound.outbound.status, 'OUTBOUNDED')
|
||||
assert.equal(delivery.order.dispatchNo, fixture.dispatchNo); assert.equal(delivery.order.status, 'DELIVERED')
|
||||
assert.equal(id(delivery.order.customerId), member.customerId); assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode)
|
||||
const expected = extension.expectedFees[member.customerId]; assert.equal(delivery.fees.length, expected.length)
|
||||
for (const fee of delivery.fees) {
|
||||
assert(!allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id))
|
||||
assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount)))
|
||||
}
|
||||
const stock = await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId })
|
||||
assert.deepEqual(stock, old.snapshots.BEFORE_SIGN_1.members[index].stock, 'Terminal completion must not change already shipped inventory')
|
||||
current.members.push({ outbound, delivery, stock })
|
||||
}
|
||||
assert.deepEqual(await get(path), current.dispatch, 'Terminal state changed during reconciliation')
|
||||
const verified = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo,
|
||||
tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, members: clone(old.members), status: 'VERIFIED', snapshots: { FINAL: current },
|
||||
continuation: 'TERMINAL_READONLY_AFTER_CONFIRMED_SIGNATURES', priorError: prior.error,
|
||||
gateProof: old.gateProof, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' }
|
||||
extension.status = 'VERIFIED'; await context.checkpoint(); return verified
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { applyIndependentFees, feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, assertBolPages } from './dispatch-workflow-extension-acceptance.mjs'
|
||||
import { parseExtensionArguments, runDispatchExtensions } from '../dispatch-workflow-extensions-test-env.mjs'
|
||||
|
||||
test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => {
|
||||
const plan = await runDispatchExtensions(parseExtensionArguments([]), {})
|
||||
assert.equal(plan.execute, false); assert.equal(plan.matrix.length, 5)
|
||||
assert.equal(extensionMatrix.find(row => row.scenario === 'relabel').readiness, 'REQUIRES_FORMAL_RELABEL_FIXTURE')
|
||||
})
|
||||
test('execution requires fresh explicit TEST scope and declared synthetic load, never coerces a plan', () => {
|
||||
const args = ['--execute', '--environment', 'TEST', '--run-id', 'EXT1', '--scenario', 'dual-destination', '--warehouse-id', '10',
|
||||
'--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1']
|
||||
assert.equal(parseExtensionArguments(args).execute, true)
|
||||
for (const [from, to] of [['TEST', 'DEV'], ['EXT1', 'plan'], ['dual-destination', 'relabel'], ['10', ''], ['1', '0']])
|
||||
assert.throws(() => parseExtensionArguments(args.map(value => value === from ? to : value)))
|
||||
assert.throws(() => parseExtensionArguments([...args, '--execute']))
|
||||
})
|
||||
test('terminal continuation requires an explicit original report hash and is never an implicit replay', () => {
|
||||
assert.throws(() => parseExtensionArguments(['--scenario', 'review-terminal']))
|
||||
assert.throws(() => parseExtensionArguments(['--resume-report', 'reports/old/report.json']))
|
||||
const config = parseExtensionArguments(['--execute', '--environment', 'TEST', '--run-id', 'EXTCLOSE1', '--scenario', 'review-terminal',
|
||||
'--warehouse-id', '10', '--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1',
|
||||
'--resume-report', 'reports/current/report.json', '--resume-report-sha256', 'a'.repeat(64)])
|
||||
assert.equal(config.scenario, 'review-terminal'); assert.equal(config.resumeReportSha256, 'a'.repeat(64))
|
||||
})
|
||||
test('actual group owner controls distinct cents even when server group ordering differs', () => {
|
||||
const groups = [{ groupKey: 'B-GROUP', customerId: 'B' }, { groupKey: 'A-GROUP', customerId: 'A' }]
|
||||
const body = { recordVersion: '9007199254740993', idempotencyKey: 'ORIGINAL', groups: groups.map(row => ({ groupKey: row.groupKey,
|
||||
fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30 }] })) }
|
||||
const expected = applyIndependentFees(body, groups, [{ customerId: 'A' }, { customerId: 'B' }], 'CURRENT')
|
||||
assert.deepEqual(body.groups.map(row => row.fees[0].amount), [47.75, 31.25])
|
||||
assert.equal(expected.A[0].cents, 3125); assert.equal(expected.B[0].cents, 4775)
|
||||
assert.equal(body.recordVersion, '9007199254740993'); assert.equal(body.idempotencyKey, 'ORIGINAL')
|
||||
assert(body.groups.every(row => row.fees[0].feeName.includes('TEST合成')))
|
||||
})
|
||||
test('unknown customers, repeated groups and missing real required fees fail before transport', () => {
|
||||
const members = [{ customerId: 'A' }, { customerId: 'B' }]
|
||||
for (const groups of [[{ groupKey: 'G', customerId: 'A' }, { groupKey: 'G', customerId: 'B' }],
|
||||
[{ groupKey: 'A', customerId: 'A' }, { groupKey: 'X', customerId: 'FOREIGN' }]]) {
|
||||
const body = { groups: groups.map(row => ({ groupKey: row.groupKey, fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY' }] })) }
|
||||
assert.throws(() => applyIndependentFees(body, groups, members, 'CURRENT'))
|
||||
}
|
||||
assert.throws(() => applyIndependentFees({ groups: [{ groupKey: 'A', fees: [] }, { groupKey: 'B', fees: [] }] },
|
||||
[{ groupKey: 'A', customerId: 'A' }, { groupKey: 'B', customerId: 'B' }], members, 'CURRENT'))
|
||||
})
|
||||
test('dual destination proof rejects reused customer, collapsed destination and missing route stop', () => {
|
||||
const fixture = { members: [{ customerId: 'A', destination: { key: 'FBA:ONE' }, receiptVerified: true },
|
||||
{ customerId: 'B', destination: { key: 'FBA:TWO' }, receiptVerified: true }],
|
||||
tripProof: { stops: [{}, { destinationKey: 'FBA:ONE' }, { destinationKey: 'FBA:TWO' }] } }
|
||||
assert.doesNotThrow(() => assertDualDestinationFixture(fixture))
|
||||
for (const mutate of [f => { f.members[1].customerId = 'A' }, f => { f.members[1].destination.key = 'FBA:ONE' },
|
||||
f => { f.tripProof.stops.pop() }]) { const bad = structuredClone(fixture); mutate(bad); assert.throws(() => assertDualDestinationFixture(bad)) }
|
||||
})
|
||||
test('fee conservation uses integer cents and rejects unsafe, negative or overprecision amounts', () => {
|
||||
assert.equal(feeCents('31.25'), 3125); assert.equal(feeCents(47.75), 4775)
|
||||
for (const value of [null, false, -1, '1e2', '0.001', Number.MAX_SAFE_INTEGER]) assert.throws(() => feeCents(value))
|
||||
})
|
||||
|
||||
for (const unknown of [false, true]) test(`bounded two-actor race ${unknown ? 'preserves unknown result and stops' : 'starts both requests before either returns and conserves inventory'}`, async () => {
|
||||
const fixture = { dispatchNo: 'OWN', tripNo: 'TRIP', warehouseId: 'W', pdaToken: 'OWN_PDA_TOKEN', members: [{ customerId: 'A' }, { customerId: 'B' }] }
|
||||
const context = { marker: 'CURRENT', createdResources: { dispatchWorkflowCompetition: { status: 'CHECKING' } },
|
||||
checkpoint: async () => {}, assert: () => {} }
|
||||
const acknowledgement = { recordVersion: '8', result: { confirmedLoad: { pallets: ['P1', 'P2'] } } }
|
||||
const body = { recordVersion: '7', idempotencyKey: 'ORIGINAL', measurements: ['P1', 'P2'] }, requests = [], waiting = []
|
||||
const send = async (method, path, payload, token) => {
|
||||
requests.push({ method, path, payload: structuredClone(payload), token })
|
||||
if (method === 'GET') return { http: 200, body: { code: '200', data: { immutable: path } } }
|
||||
return new Promise((resolve, reject) => {
|
||||
waiting.push({ resolve, reject, token })
|
||||
if (waiting.length === 2) for (const entry of waiting) {
|
||||
if (entry.token) entry.resolve({ http: 200, body: { code: '200', data: { ...acknowledgement, replayed: true } } })
|
||||
else if (unknown) entry.reject(Error('Unknown write outcome; preserve original key'))
|
||||
else entry.resolve({ http: 200, body: { code: 'A0403', message: '该请求标识不属于当前操作员' } })
|
||||
}
|
||||
})
|
||||
}
|
||||
const execute = () => verifyLoadingReplayRace(context, fixture, { readInventory: async scope => ({ ...scope, available: 0, picked: 1 }) }, send, body, acknowledgement)
|
||||
if (unknown) await assert.rejects(execute(), /Unknown write outcome/)
|
||||
else await execute()
|
||||
assert.equal(waiting.length, 2)
|
||||
assert.equal(requests.filter(row => row.method === 'POST').length, 2)
|
||||
assert(requests.filter(row => row.method === 'POST').every(row => JSON.stringify(row.payload) === JSON.stringify(body)))
|
||||
const proof = context.createdResources.dispatchWorkflowCompetition.loadingReplayRace
|
||||
assert.equal(proof.status, unknown ? 'UNKNOWN_RESULT' : 'VERIFIED')
|
||||
assert.equal(proof.firstExecutionRace, 'NOT_TESTED')
|
||||
if (!unknown) assert.deepEqual(proof.before, proof.after)
|
||||
})
|
||||
|
||||
test('BOL PDF evidence enforces empty/same/different appointment grouping and actual quantity conservation', () => {
|
||||
const fixture = { members: [{ palletNo: 'PALLET_A', incomingNo: 'PALLET_A' }, { palletNo: 'PALLET_B', incomingNo: 'PALLET_B' }] }
|
||||
const pdf = (isa, pallets, total) => `BILL OF LADING\nISA #: ${isa}\n${pallets.join('\n')}\n总箱数 / Total CTNS:\n${total}\nDriver signature`
|
||||
assert.equal(assertBolPages([pdf('', ['PALLET_A', 'PALLET_B'], 2)], fixture, { appointments: [] }, 'bol-empty').totalPackages, 2)
|
||||
assert.equal(assertBolPages([pdf('ISA1', ['PALLET_A', 'PALLET_B'], 2)], fixture,
|
||||
{ appointments: [{ data: { confirmationNo: 'ISA1' } }] }, 'bol-same').pageCount, 1)
|
||||
const appointments = { appointments: [{ data: { confirmationNo: 'ISA1' } }, { data: { confirmationNo: 'ISA2' } }] }
|
||||
const pages = [pdf('ISA1', ['PALLET_A'], 1), pdf('ISA2', ['PALLET_B'], 1)]
|
||||
assert.equal(assertBolPages(pages, fixture, appointments, 'bol-different').pageCount, 2)
|
||||
for (const bad of [[pdf('ISA1', ['PALLET_A'], 2), pdf('ISA2', ['PALLET_B'], 2)],
|
||||
[pdf('ISA1', ['PALLET_A', 'PALLET_B'], 1), pdf('ISA2', ['PALLET_B'], 1)],
|
||||
[pdf('ISA1', ['PALLET_A'], 1), pdf('ISA1', ['PALLET_B'], 1)]])
|
||||
assert.throws(() => assertBolPages(bad, fixture, appointments, 'bol-different'))
|
||||
})
|
||||
@@ -26,7 +26,7 @@ function model(fault = '', countPatch = {}) {
|
||||
let native
|
||||
const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId,
|
||||
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0,
|
||||
destination: { key: 'FBA:FBA' } }))
|
||||
destination: { key: `FBA:${transfer.destinationWarehouseCode}` } }))
|
||||
const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' }
|
||||
const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture },
|
||||
createdResources: { merchantQualificationFixture: { marker: 'PARENT' } },
|
||||
@@ -51,6 +51,8 @@ function model(fault = '', countPatch = {}) {
|
||||
if (path.startsWith('/api/pda/')) assert.equal(token, options.pdaToken)
|
||||
if (path.endsWith('/warehouse-admin/10')) return response(origin)
|
||||
if (path.endsWith('/warehouse-admin/20')) return response(destination)
|
||||
if (path.endsWith('/warehouse-admin/21')) return response({ ...destination,
|
||||
warehouse: { ...destination.warehouse, id: '21', warehouseCode: 'FBA2', warehouseName: 'Second destination' } })
|
||||
if (path === '/api/platform/v1/iam/tenants') {
|
||||
const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n)
|
||||
const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark }
|
||||
@@ -80,7 +82,7 @@ function model(fault = '', countPatch = {}) {
|
||||
const transferNo = `IN${transfers.length}`
|
||||
const itemId = `FORECAST-ITEM${transfers.length}`
|
||||
const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId,
|
||||
status: 'DRAFT', packageCount: 1, pallets: [],
|
||||
status: 'DRAFT', packageCount: 1, pallets: [], destinationWarehouseCode: body.destinationWarehouseCode,
|
||||
items: [{ ...copy(body.items[0]), id: itemId, ...(fault === 'missing declared quantity' ? { quantity: null } : {}) }],
|
||||
cartons: fault === 'missing forecast carton' ? [] : [{ cartonMark: `BX-FORECAST${transfers.length}`, status: 'FORECAST',
|
||||
transferItemId: fault === 'foreign forecast item' ? 'OTHER-ITEM' : itemId }] }
|
||||
@@ -141,7 +143,7 @@ function model(fault = '', countPatch = {}) {
|
||||
const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n]
|
||||
assert.equal(transfer.status, 'READY_FOR_DISPATCH')
|
||||
return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId,
|
||||
warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' },
|
||||
warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: `FBA:${transfer.destinationWarehouseCode}` },
|
||||
sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] })
|
||||
}
|
||||
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') {
|
||||
@@ -152,10 +154,11 @@ function model(fault = '', countPatch = {}) {
|
||||
counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {},
|
||||
documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} }
|
||||
trip.dispatches = [{ dispatchNo: native.dispatchNo }]
|
||||
Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] })
|
||||
Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, ...[...new Set(transfers.map(row => row.destinationWarehouseCode))]
|
||||
.map(code => ({ name: code, destinationKey: `FBA:${code}` }))] })
|
||||
dispatches.push(native); return response(native)
|
||||
}
|
||||
if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) }
|
||||
if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber.toUpperCase(); return response({ id: 'VEHICLE' }) }
|
||||
if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' })
|
||||
if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' })
|
||||
if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 })
|
||||
@@ -270,6 +273,26 @@ test('native Jackson Long count strings build the same complete one-main fixture
|
||||
assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo))
|
||||
})
|
||||
|
||||
test('explicit per-member destinations are read before writes and preserved in both receipts and route', async () => {
|
||||
const m = model(); m.options.destinationWarehouseIds = ['20', '21']
|
||||
const fixture = await createFleetCrossCustomerFixture(m.context, m.options)
|
||||
assert.deepEqual(fixture.members.map(row => row.destination.key), ['FBA:FBA', 'FBA:FBA2'])
|
||||
assert.deepEqual(m.trip.trip.stops.slice(1).map(row => row.destinationKey), ['FBA:FBA', 'FBA:FBA2'])
|
||||
const secondDestination = m.calls.findIndex(row => row.raw.includes('/warehouse-admin/21'))
|
||||
const firstWrite = m.calls.findIndex(row => row.method !== 'GET')
|
||||
assert.ok(secondDestination >= 0 && secondDestination < firstWrite)
|
||||
assert.deepEqual(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/merchant/v1/last-leg/warehouse-transfers')
|
||||
.map(row => row.body.destinationWarehouseId), ['20', '21'])
|
||||
})
|
||||
|
||||
test('malformed member destination scope fails without network', async () => {
|
||||
for (const destinations of [[], ['20'], ['20', '21', '22'], ['21', '20'], ['20', '']]) {
|
||||
const m = model(); m.options.destinationWarehouseIds = destinations
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /Two explicit/)
|
||||
assert.equal(m.calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => {
|
||||
for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) {
|
||||
assert.equal(safeFixtureCount(value), expected)
|
||||
|
||||
@@ -32,11 +32,19 @@ export const preparationContract = Object.freeze({
|
||||
})
|
||||
|
||||
/** Preparation is opt-in and only for separately authorized TEST execution. Import/default call is offline. */
|
||||
export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, pdaToken, readInventory } = {}) {
|
||||
export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, destinationWarehouseIds,
|
||||
shipmentReferences, prepareBatchAllocations, pdaToken, readInventory } = {}) {
|
||||
if (!execute) return preparationContract
|
||||
if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function'
|
||||
|| ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function'))
|
||||
throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader')
|
||||
const memberDestinations = destinationWarehouseIds ?? [destinationWarehouseId, destinationWarehouseId]
|
||||
if (shipmentReferences !== undefined && (!Array.isArray(shipmentReferences) || shipmentReferences.length !== 2
|
||||
|| shipmentReferences.some(value => typeof value !== 'string' || !value.trim()))) throw Error('Two explicit TEST shipment references required')
|
||||
if (prepareBatchAllocations !== undefined && typeof prepareBatchAllocations !== 'function') throw Error('Appointment preparation must be callable')
|
||||
if (!Array.isArray(memberDestinations) || memberDestinations.length !== 2
|
||||
|| memberDestinations.some(value => !id(value)) || id(memberDestinations[0]) !== id(destinationWarehouseId))
|
||||
throw new Error('Two explicit member destinations must start with the primary destination')
|
||||
context.createdResources ??= {}
|
||||
if (context.createdResources.fleetCrossCustomerFixture) throw new Error('Fixture preparation already started; retain partial resources and reconcile explicitly')
|
||||
const proof = context.createdResources.fleetCrossCustomerFixture = {
|
||||
@@ -106,18 +114,23 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
try {
|
||||
// Explicit IDs are re-read through the public master-data API before any fixture writes.
|
||||
const origin = await request('GET', `/api/platform/v1/warehouse-admin/${encode(warehouseId)}?tenantId=0`, undefined, 'Verify receiving warehouse')
|
||||
const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationWarehouseId)}?tenantId=0`, undefined, 'Verify FBA destination')
|
||||
const validMaster = (detail, expectedId, type) => id(detail?.warehouse?.id) === id(expectedId)
|
||||
&& id(detail.warehouse.tenantId) === '0' && detail.warehouse.status === 'ACTIVE' && detail.warehouse.warehouseType === type
|
||||
&& detail.warehouse.warehouseCode && detail.warehouse.warehouseName
|
||||
check('Origin and destination are current public active warehouse masters', validMaster(origin, warehouseId, 'OVERSEAS')
|
||||
&& origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true)
|
||||
&& validMaster(destination, destinationWarehouseId, 'FBA'))
|
||||
const address = destination.addresses?.find(row => row.addressLine1)
|
||||
check('FBA destination has a real address', Boolean(address))
|
||||
const deliveryAddress = [address.countryCode, address.stateProvince, address.city, address.district,
|
||||
address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ')
|
||||
&& origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true))
|
||||
const destinations = new Map()
|
||||
for (const destinationId of [...new Set(memberDestinations.map(id))]) {
|
||||
const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationId)}?tenantId=0`, undefined, 'Verify FBA destination')
|
||||
check('Each explicit destination is a public active FBA master', validMaster(destination, destinationId, 'FBA'))
|
||||
const address = destination.addresses?.find(row => row.addressLine1)
|
||||
check('FBA destination has a real address', Boolean(address))
|
||||
destinations.set(destinationId, { destination, deliveryAddress: [address.countryCode, address.stateProvince, address.city, address.district,
|
||||
address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ') })
|
||||
}
|
||||
for (const suffix of ['A', 'B']) {
|
||||
const memberDestinationId = id(memberDestinations[suffix === 'A' ? 0 : 1])
|
||||
const { destination, deliveryAddress } = destinations.get(memberDestinationId)
|
||||
const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false }
|
||||
proof.members.push(member)
|
||||
// Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot.
|
||||
@@ -147,11 +160,12 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken)
|
||||
check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId)
|
||||
const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId),
|
||||
fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: id(destinationWarehouseId),
|
||||
fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: memberDestinationId,
|
||||
destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName,
|
||||
deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000),
|
||||
needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`),
|
||||
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] }
|
||||
if (shipmentReferences) createBody.fbaShipmentNo = shipmentReferences[proof.members.length - 1]
|
||||
let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken)
|
||||
member.incomingNo = incoming?.transferNo
|
||||
check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo
|
||||
@@ -234,6 +248,20 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
proof.createDispatchRequest = { idempotencyKey: key('CREATE_ONE_NATIVE_DISPATCH'), warehouseId: id(warehouseId),
|
||||
vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null,
|
||||
remark: `${context.marker} TEST跨客户同仓一张排车一趟车`, cargo: proof.members.map(member => member.selection), batchAllocations: [] }
|
||||
if (prepareBatchAllocations) {
|
||||
proof.createDispatchRequest.batchAllocations = await prepareBatchAllocations(proof.members)
|
||||
if (!Array.isArray(proof.createDispatchRequest.batchAllocations)) throw Error('Formal appointment allocations must be explicit')
|
||||
for (const member of proof.members) {
|
||||
const current = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`,
|
||||
undefined, 'Refresh actual candidate after formal appointment creation')
|
||||
const matches = current.candidates?.filter(row => id(row.customerId) === member.customerId && id(row.selection?.warehousePalletId) === member.palletId)
|
||||
check('Appointment preparation preserves exact received physical source', matches?.length === 1 && current.complete === true
|
||||
&& matches[0].selection?.sourceType === 'INBOUND_PLAN' && matches[0].blockReasons?.length === 0)
|
||||
member.selection = matches[0].selection
|
||||
}
|
||||
proof.createDispatchRequest.cargo = proof.members.map(member => member.selection)
|
||||
await context.checkpoint?.()
|
||||
}
|
||||
const created = await request('POST', `${ROOT}/dispatches`, proof.createDispatchRequest, 'Create one native dispatch from both real customers')
|
||||
check('One native Create returns both unpicked pallets without client-supplied customer identity', created?.dispatchNo && created.status === 'ACTIVE'
|
||||
&& created.stage === 'BEFORE_LOADING' && safeFixtureCount(created.counts?.packageCount) === 2 && safeFixtureCount(created.counts?.pickedPackageCount) === 0 && safeFixtureCount(created.counts?.shippedPackageCount) === 0)
|
||||
|
||||
@@ -47,7 +47,14 @@ export async function dispatchEvidenceContext(context) {
|
||||
}
|
||||
export function query(path, values) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') params.set(key, String(value))
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined || value === null || value === '') continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) if (item !== undefined && item !== null && item !== '') params.append(key, String(item))
|
||||
continue
|
||||
}
|
||||
params.set(key, String(value))
|
||||
}
|
||||
return `${path}?${params}`
|
||||
}
|
||||
export function commandKey(marker, namespace, action) {
|
||||
@@ -112,8 +119,8 @@ export function dispatchIo(context, module, proof, namespace) {
|
||||
const boundary = context.createdResources.dispatchWriteBoundary ??= { marker: context.marker, stopped: false }
|
||||
if (!context.marker || boundary.marker !== context.marker) throw new DispatchNotRun('请求边界不属于本轮marker')
|
||||
const checkpoint = async () => { if (typeof context.checkpoint === 'function') await context.checkpoint() }
|
||||
const check = (label, response, passed) => {
|
||||
context.assert?.(module, label, response, passed, `marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true })
|
||||
const check = (label, response, passed, detail = '') => {
|
||||
context.assert?.(module, label, response, passed, `${detail ? `${detail}; ` : ''}marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true })
|
||||
if (!passed) throw Error(`${namespace}: ${label}`)
|
||||
}
|
||||
const send = async (method, path, body, label) => {
|
||||
@@ -136,7 +143,8 @@ export function dispatchIo(context, module, proof, namespace) {
|
||||
}
|
||||
const command = async (method, path, body, label) => { const response = await send(method, path, body, label); check(label, response, success(response)); return response.body.data }
|
||||
const key = action => commandKey(context.marker, namespace, action)
|
||||
return { check, send, command, key }
|
||||
return { check, send, command, key,
|
||||
planIds: (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String) }
|
||||
}
|
||||
export function replayFacts(result) { const copy = structuredClone(result); if (copy && typeof copy === 'object') delete copy.replayed; return copy }
|
||||
export async function replay(io, path, body, result, label, method = 'POST') {
|
||||
@@ -145,7 +153,9 @@ export async function replay(io, path, body, result, label, method = 'POST') {
|
||||
return again
|
||||
}
|
||||
export async function candidatePage(io, warehouseId, extra = {}) {
|
||||
const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, { warehouseId, sourceStage: 'RECEIVED', ...extra }), undefined, '读取同仓原生候选')
|
||||
const planIds = extra.planIds ?? io?.planIds
|
||||
const params = { warehouseId, sourceStage: 'RECEIVED', ...(Array.isArray(planIds) && planIds.length ? { planIds } : {}), ...extra }
|
||||
const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, params), undefined, '读取同仓原生候选')
|
||||
io.check('候选完整包络', null, result?.complete === true && Array.isArray(result.candidates))
|
||||
return result.candidates
|
||||
}
|
||||
|
||||
@@ -251,38 +251,45 @@ async function reusablePallets(context, warehouseId) {
|
||||
if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey)
|
||||
|
||||
const pallets = []
|
||||
if (allowAnyExistingPallet) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response,
|
||||
`warehouseId=${warehouseId}`, OPTIONS)
|
||||
const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey
|
||||
&& asId(item?.id) && String(item?.palletCode || '').trim())
|
||||
context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0,
|
||||
`warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS)
|
||||
if (candidates.length === 0) return null
|
||||
const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) }))
|
||||
reusablePalletsByWarehouse.set(scopedCacheKey, selected)
|
||||
return selected
|
||||
}
|
||||
for (const palletCode of REUSABLE_PALLET_CODES) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`)
|
||||
const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response,
|
||||
`warehouseId=${warehouseId}`, OPTIONS)
|
||||
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
|
||||
&& String(item?.palletCode || '') === palletCode && asId(item?.id))
|
||||
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
|
||||
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=disabled`, OPTIONS)
|
||||
if (!exact) {
|
||||
context.assert(MODULE, '固定称重托盘主数据完整', response, false,
|
||||
`warehouseId=${warehouseId}, missing=${palletCode}`, OPTIONS)
|
||||
return null
|
||||
const missingFixed = []
|
||||
if (!allowAnyExistingPallet) {
|
||||
for (const palletCode of REUSABLE_PALLET_CODES) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`)
|
||||
const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response,
|
||||
`warehouseId=${warehouseId}`, OPTIONS)
|
||||
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
|
||||
&& String(item?.palletCode || '') === palletCode && asId(item?.id))
|
||||
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
|
||||
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}`, OPTIONS)
|
||||
if (!exact) {
|
||||
missingFixed.push(palletCode)
|
||||
continue
|
||||
}
|
||||
pallets.push({ id: asId(exact.id), code: palletCode })
|
||||
}
|
||||
pallets.push({ id: asId(exact.id), code: palletCode })
|
||||
if (missingFixed.length === 0) {
|
||||
reusablePalletsByWarehouse.set(scopedCacheKey, pallets)
|
||||
return pallets
|
||||
}
|
||||
// 固定称重托盘是环境初始化样本;缺失时降级使用当前仓库已有托盘,测量与计费仍走真实接口。
|
||||
context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失',
|
||||
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')};改用当前仓库已有托盘继续`,
|
||||
'fixture', { blocking: false })
|
||||
}
|
||||
reusablePalletsByWarehouse.set(scopedCacheKey, pallets)
|
||||
return pallets
|
||||
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response,
|
||||
`warehouseId=${warehouseId}`, OPTIONS)
|
||||
const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey
|
||||
&& asId(item?.id) && String(item?.palletCode || '').trim())
|
||||
context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0,
|
||||
`warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS)
|
||||
if (candidates.length === 0) return null
|
||||
const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) }))
|
||||
reusablePalletsByWarehouse.set(scopedCacheKey, selected)
|
||||
return selected
|
||||
}
|
||||
|
||||
function randomlyReusePalletIds(pallets, palletCount, randomValue) {
|
||||
|
||||
@@ -48,7 +48,7 @@ function server(fault = '', initial = false) {
|
||||
if (path === `${BASE}/candidates`) {
|
||||
assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED')
|
||||
const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey)))
|
||||
for (const d of drafts.values()) if (d.status === 'ACTIVE' && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey)
|
||||
for (const d of drafts.values()) if (d.status === 'ACTIVE' && params.get('draftToken') && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey)
|
||||
return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' })
|
||||
}
|
||||
if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: body.capacity, sourceKeys: [] }); draft.recordVersion++; return remember(draft) }
|
||||
|
||||
@@ -129,7 +129,12 @@ async function prepareDraft(context, io, candidates) {
|
||||
const own = await candidatePage(io, candidates[0].warehouseId, { draftToken: draft.draftToken })
|
||||
io.check('原草稿仅放行自己的可选来源', null, candidates.every(c => own.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
|
||||
const hidden = await candidatePage(io, candidates[0].warehouseId)
|
||||
io.check('无草稿身份不能选择活动占用', null, candidates.every(c => !hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
|
||||
io.check('草稿锁为软占用不阻断其他草稿候选', null,
|
||||
candidates.every(c => hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)),
|
||||
`hidden=${JSON.stringify(candidates.map(c => ({ key: c.sourceKey, reasons: hidden.filter(a => a.sourceKey === c.sourceKey).map(a => a.blockReasons) }))).slice(0, 600)}`)
|
||||
context.addCoverageGap?.(MODULE, 'dispatch-occupancy', '正式排车活动占用阻断',
|
||||
'草稿锁是软占用,不阻断其他草稿;正式排车占用阻断由 grouped 正式整车转换与 removeControl 回查覆盖',
|
||||
'not-implemented', { blocking: false })
|
||||
return draft
|
||||
}
|
||||
async function createRelabel(context, io, proof) {
|
||||
@@ -152,8 +157,10 @@ export async function run(context) {
|
||||
const proof = context.createdResources.phase4OverseasDispatch = { marker: context.marker, status: 'CHECKING', dispatchNo: null, finalStatus: null, stage: null, requests: [] }
|
||||
try {
|
||||
const fixture = context.createdResources.phase4OverseasPda
|
||||
const dispatchPlanIds = () => (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String)
|
||||
markerCandidates(context, [])
|
||||
let io = dispatchIo(context, MODULE, proof, 'MAIN')
|
||||
io.planIds = dispatchPlanIds()
|
||||
const page = await candidatePage(io, fixture.overseasWarehouseId)
|
||||
const routeFixture = selectDispatchRouteFixture(markerCandidates(context, page))
|
||||
if (!routeFixture) throw new DispatchNotRun('本轮同客户仓需两个FBA目的地及同目的地双实托,不借历史样本')
|
||||
@@ -161,6 +168,7 @@ export async function run(context) {
|
||||
;[...selected, routeFixture.duplicate].forEach((c, n) => createBody(context.marker, `PRECHECK-${n}`, [c]))
|
||||
context = await dispatchEvidenceContext(context)
|
||||
io = dispatchIo(context, MODULE, proof, 'MAIN')
|
||||
io.planIds = dispatchPlanIds()
|
||||
proof.removalDispatchNo = await removeControl(context, io, selected[0])
|
||||
const grouped = await verifyGroupedDispatch(context, MODULE, selected)
|
||||
if (grouped.status !== 'GROUPED_DISPATCH_VERIFIED') throw new DispatchNotRun(grouped.reason)
|
||||
|
||||
@@ -429,7 +429,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
shipmentNo: `SHIP-${context.marker}-${index + 1}`.slice(0, 128),
|
||||
newSkuCode,
|
||||
labelRequirement: context.marker,
|
||||
cartonMark: `CM-${String(context.marker).slice(-28)}-${index + 1}`,
|
||||
cartonMark: nonBlank(source.cartonMark) ? source.cartonMark : `CM-${String(context.marker).slice(-28)}-${index + 1}`,
|
||||
fbaCartonMark: changesCartonLabel ? `FBA-${String(context.marker).slice(-27)}-${index + 1}` : null,
|
||||
destinationFbaWarehouseCode: changesCartonLabel ? source.destinationFbaWarehouseCode : null,
|
||||
operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL',
|
||||
|
||||
@@ -374,6 +374,11 @@ export async function disablePicker(context, picker, index) {
|
||||
function rememberGeneratedPlans(context, plans) {
|
||||
if (!Array.isArray(plans)) return
|
||||
for (const plan of plans) {
|
||||
const planId = id(plan?.id || plan?.planId)
|
||||
if (longId(planId)) {
|
||||
const ids = context.createdResources.phase4OverseasPda.generatedPlanIds ??= []
|
||||
if (!ids.includes(planId)) ids.push(planId)
|
||||
}
|
||||
const inboundCode = id(plan?.lines?.[0]?.inboundCode)
|
||||
if (!nonBlank(inboundCode)) continue
|
||||
const generated = Array.isArray(plan?.pallets) ? plan.pallets : []
|
||||
@@ -1172,6 +1177,7 @@ export async function run(context) {
|
||||
completedContainerFacts: [],
|
||||
overseasWarehouseId: null, overseasWarehouseName: null,
|
||||
generatedPalletsByInbound: {},
|
||||
generatedPlanIds: [],
|
||||
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
|
||||
}
|
||||
const merchantToken = await loginMerchantForNegativeProbe(context)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { dirname, resolve, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { runCrossCustomer } from './fleet-cross-customer-test-env.mjs'
|
||||
import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs'
|
||||
import { finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
|
||||
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
|
||||
import { fixtureContract } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs'
|
||||
import { discoverSecondDestination, verifyDualDestination, verifyReviewExtension, verifyCompetitionExtension, prepareBolAppointments, verifyBolExtension, reconcileReviewTerminal, extensionMatrix } from './admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const hash = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
export function parseExtensionArguments(args) {
|
||||
const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'dual-destination', warehouseId: '', destinationWarehouseId: '', pdaEnvFile: '', syntheticPalletSpaces: null, resumeReport: '', resumeReportSha256: '', pdfPython: '' }
|
||||
const seen = new Set()
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const name = args[index]
|
||||
if (['--plan', '--execute'].includes(name)) {
|
||||
if (seen.has('mode')) throw Error('Duplicate mode'); seen.add('mode'); config.execute = name === '--execute'; continue
|
||||
}
|
||||
const field = { '--environment': 'environment', '--run-id': 'runId', '--scenario': 'scenario', '--warehouse-id': 'warehouseId',
|
||||
'--destination-warehouse-id': 'destinationWarehouseId', '--pda-env-file': 'pdaEnvFile', '--synthetic-pallet-spaces': 'syntheticPalletSpaces',
|
||||
'--resume-report': 'resumeReport', '--resume-report-sha256': 'resumeReportSha256', '--pdf-python': 'pdfPython' }[name]
|
||||
if (!field || seen.has(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported/duplicate argument')
|
||||
seen.add(name); config[field] = args[++index]
|
||||
}
|
||||
if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)) throw Error('Explicit TEST/fresh run ID required')
|
||||
if (config.execute && (!['dual-destination', 'reviews', 'competition', 'review-terminal', 'bol-empty', 'bol-same', 'bol-different'].includes(config.scenario) || config.runId === 'plan' || !config.pdaEnvFile
|
||||
|| !/^[1-9]\d*$/.test(config.warehouseId) || !/^[1-9]\d*$/.test(config.destinationWarehouseId) || config.syntheticPalletSpaces !== '1'))
|
||||
throw Error('Ready scenario, explicit warehouse/destination/PDA config and synthetic pallet-spaces=1 required')
|
||||
if (config.scenario === 'review-terminal' && (!config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256))) throw Error('Explicit frozen review report required')
|
||||
if (config.scenario !== 'review-terminal' && (config.resumeReport || config.resumeReportSha256)
|
||||
&& (!config.scenario.startsWith('bol-') || !config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256)))
|
||||
throw Error('Resume options require an explicit frozen owned checkpoint')
|
||||
if (config.execute && config.scenario.startsWith('bol-') && !config.pdfPython) throw Error('Explicit PDF reader runtime required')
|
||||
return config
|
||||
}
|
||||
export async function runDispatchExtensions(config, env = process.env) {
|
||||
if (!config.execute) return { execute: false, environment: 'TEST', matrix: extensionMatrix,
|
||||
limits: 'No deploy, SQL writes, permission edits, deletion, old manual main mutation, blind retries or uncontrolled fault injection.' }
|
||||
if (env.CHONGCHONG_ENV !== 'test') throw Error('Only the explicitly loaded TEST configuration is accepted')
|
||||
const bol = config.scenario.startsWith('bol-')
|
||||
if (bol) execFileSync(config.pdfPython, ['-c', 'import pypdf'], { stdio: 'pipe', timeout: 10000 })
|
||||
let prior
|
||||
if (config.scenario === 'review-terminal' || bol && config.resumeReport) {
|
||||
const sourcePath = resolve(ROOT, config.resumeReport)
|
||||
const prefix = bol ? 'fleet-cross-DISPATCH20261020EXTBOL' : 'fleet-cross-DISPATCH20261020EXTREV'
|
||||
if (!sourcePath.startsWith(join(ROOT, 'reports', prefix))) throw Error('Only this extension-owned frozen report can be reconciled')
|
||||
const bytes = readFileSync(sourcePath)
|
||||
if (hash(bytes) !== config.resumeReportSha256) throw Error('Original review report changed')
|
||||
prior = JSON.parse(bytes)
|
||||
if (bol) {
|
||||
if (prior.success !== false || prior.error !== 'Expected values to be strictly equal:\n\n0 !== 1\n'
|
||||
|| prior.createdResources?.dispatchWorkflowBol?.status !== 'CHECKING' || prior.createdResources.dispatchWorkflowBol.scenario !== config.scenario
|
||||
|| prior.createdResources?.fleetCrossCustomerFixture?.status !== 'READY' || prior.createdResources?.fleetCrossCustomer
|
||||
|| prior.createdResources?.fleetWriteBoundary || prior.createdResources?.fleetTrips?.[0]?.closure)
|
||||
throw Error('Not the bounded PDF long-pallet-code assertion checkpoint')
|
||||
} else if (prior.success !== false || prior.error !== 'Native main/items/PDA retain both true customers and physical pallets'
|
||||
|| prior.createdResources?.dispatchWorkflowReviews?.status !== 'VERIFIED' || prior.createdResources?.fleetWriteBoundary
|
||||
|| prior.createdResources?.fleetTrips?.[0]?.closure) throw Error('Not the bounded known terminal-read race')
|
||||
for (let number = 1; number <= prior.requestCount; number++)
|
||||
if (JSON.parse(readFileSync(join(dirname(sourcePath), 'requests', `${number}.json`))).stage !== 'RESPONSE') throw Error('Prior request outcome uncertain; terminal mode forbidden')
|
||||
}
|
||||
const reportDirectory = join(ROOT, 'reports', `fleet-cross-${config.runId}`)
|
||||
const intentPath = join(ROOT, 'reports', `dispatch-workflow-${config.runId}.intent.json`)
|
||||
if (existsSync(reportDirectory) || existsSync(intentPath)) throw Error('Previous attempt exists; retain original requests and reconcile, never re-enter')
|
||||
const pda = Object.fromEntries(readFileSync(config.pdaEnvFile, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)
|
||||
.filter(line => /^[A-Z][A-Z0-9_]*=/.test(line)).map(line => { const separator = line.indexOf('=');
|
||||
return [line.slice(0, separator), line.slice(separator + 1).trim().replace(/^["']|["']$/g, '')] }))
|
||||
if (!pda.PDA_USERNAME || !pda.PDA_PASSWORD) throw Error('Missing authorized existing overseas PDA credentials')
|
||||
const files = ['scripts/dispatch-workflow-extensions-test-env.mjs', 'scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs',
|
||||
'scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs', 'scripts/fleet-cross-customer-test-env.mjs',
|
||||
'scripts/admin-test-env-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs']
|
||||
const seal = Object.fromEntries(files.map(path => [path, hash(readFileSync(join(ROOT, path)))]))
|
||||
const stable = () => { for (const [path, expected] of Object.entries(seal)) if (hash(readFileSync(join(ROOT, path))) !== expected) throw Error(`Source drift: ${path}`) }
|
||||
mkdirSync(join(ROOT, 'reports'), { recursive: true })
|
||||
writeFileSync(intentPath, JSON.stringify({ runId: config.runId, scenario: config.scenario, environment: 'TEST', seal,
|
||||
createdAt: new Date().toISOString(), syntheticTestMaterials: true, physicalMeasurementVerified: false, hardwareVerified: false,
|
||||
originalManualMain: 'READ_ONLY_NOT_USED', resumeReportSha256: prior ? config.resumeReportSha256 : null,
|
||||
midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' }, null, 2), { flag: 'wx' })
|
||||
const result = await runCrossCustomer(config, {
|
||||
env: { ...env, DISPATCH_EXISTING_PDA_PASSWORD: pda.PDA_PASSWORD },
|
||||
contextFactory: options => { const context = new AcceptanceRun(options); if (prior) context.marker = prior.marker; return context },
|
||||
pickerCreate: async context => {
|
||||
context.createdResources.extensionIdentity = { mode: 'EXISTING_TEST_PDA_OWNED_NEW_SESSION', accountMutation: false }
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: pda.PDA_USERNAME, password: pda.PDA_PASSWORD }, '')
|
||||
if (response.http !== 200 || String(response.body?.code) !== '200' || !response.body.data?.accessToken || !response.body.data.refreshToken) throw Error('Existing PDA login failed')
|
||||
return { membershipId: null, password: pda.PDA_PASSWORD, token: response.body.data.accessToken, refreshToken: response.body.data.refreshToken }
|
||||
},
|
||||
pickerDisable: async () => { throw Error('Existing identity must never be disabled') },
|
||||
prepare: async (context, options) => {
|
||||
stable(); context.createdResources.extensionSourceSeal = seal
|
||||
if (prior) {
|
||||
const tripProof = structuredClone(prior.createdResources.fleetTrips[0])
|
||||
context.createdResources.fleetTrips = [tripProof]
|
||||
context.createdResources[bol ? 'explicitBolCheckpoint' : 'explicitReviewTerminal'] = { sourceReport: config.resumeReport, sourceReportSha256: config.resumeReportSha256,
|
||||
originalFailurePreserved: true, fulfillmentReplay: false, fixtureCreationReplay: false }
|
||||
if (bol) {
|
||||
context.createdResources.dispatchWorkflowBolAppointments = structuredClone(prior.createdResources.dispatchWorkflowBolAppointments)
|
||||
context.createdResources.fleetCrossCustomerFixture = structuredClone(prior.createdResources.fleetCrossCustomerFixture)
|
||||
return { marker: prior.marker, stage: fixtureContract.stage, dispatchNo: tripProof.dispatchNo, tripNo: tripProof.tripNo,
|
||||
warehouseId: config.warehouseId, pdaToken: options.pdaToken, tripProof,
|
||||
members: structuredClone(prior.createdResources.fleetCrossCustomerFixture.members),
|
||||
loadingMeasurements: structuredClone(prior.createdResources.extensionSyntheticLoadingInput) }
|
||||
}
|
||||
const members = prior.createdResources.fleetCrossCustomerFixture.members.map(member => {
|
||||
const match = prior.createdResources.fleetCrossCustomer.members.find(row => row.customerId === member.customerId)
|
||||
if (!match) throw Error('Original receipt/member attribution missing')
|
||||
return { ...structuredClone(member), ...structuredClone(match) }
|
||||
})
|
||||
return { marker: prior.marker, stage: fixtureContract.stage, dispatchNo: tripProof.dispatchNo, tripNo: tripProof.tripNo,
|
||||
warehouseId: config.warehouseId, pdaToken: options.pdaToken, tripProof, members }
|
||||
}
|
||||
if (bol) {
|
||||
const master = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(config.destinationWarehouseId)}?tenantId=0`)
|
||||
const timezone = master.body?.data?.warehouse?.timezone
|
||||
if (master.http !== 200 || String(master.body?.code) !== '200' || !/^[A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?$/.test(timezone || ''))
|
||||
throw Error('BOL appointment fixtures require an existing FBA destination with an actual regional timezone before any fixture write')
|
||||
}
|
||||
const secondId = bol ? config.destinationWarehouseId : await discoverSecondDestination(context, config.destinationWarehouseId)
|
||||
const fixture = await createFleetCrossCustomerFixture(context, { ...options, destinationWarehouseIds: [config.destinationWarehouseId, secondId],
|
||||
...(bol ? { shipmentReferences: ['A', 'B'].map(suffix => `TEST-FBA-${context.marker.slice(-12)}-${suffix}`),
|
||||
prepareBatchAllocations: members => prepareBolAppointments(context, members, config.scenario, config.warehouseId) } : {}) })
|
||||
fixture.loadingMeasurements = { environment: 'TEST', marker: context.marker, dispatchNo: fixture.dispatchNo, syntheticTestMaterials: true,
|
||||
physicalMeasurementVerified: false, hardwareVerified: false, pallets: fixture.members.map(member => ({ warehousePalletId: member.palletId,
|
||||
palletSpaces: 1, evidence: [{ field: 'palletSpaces', value: 1, actual: true, sourceType: 'TEST_SYNTHETIC_LOADING_INPUT',
|
||||
sourceBusinessNo: config.runId, observedAt: new Date().toISOString() }] })) }
|
||||
context.createdResources.extensionSyntheticLoadingInput = fixture.loadingMeasurements
|
||||
await context.checkpoint(); stable(); return fixture
|
||||
},
|
||||
verify: async (...args) => {
|
||||
stable()
|
||||
if (prior && !bol) return reconcileReviewTerminal(...args, prior)
|
||||
if (bol) {
|
||||
const [context, fixture, options] = args
|
||||
return verifyBolExtension(context, fixture, { ...options, bol: config.scenario, inspectBol: async () => {
|
||||
const evidence = join(reportDirectory, 'evidence'), pdfPath = join(evidence, 'BOL.pdf'), journal = join(evidence, 'bol-http.json')
|
||||
const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/prints/bol`
|
||||
const record = { method: 'GET', path, stage: 'INTENT', numberOfBinaryRequests: 1 }
|
||||
writeFileSync(journal, JSON.stringify(record, null, 2), { flag: 'wx' }); await context.checkpoint()
|
||||
const response = await context.requestBinary('GET', path)
|
||||
Object.assign(record, { stage: 'RESPONSE', http: response.http, contentType: response.contentType,
|
||||
requestId: response.requestId, traceId: response.traceId, bytes: response.bytes.length, sha256: hash(response.bytes) })
|
||||
writeFileSync(journal, JSON.stringify(record, null, 2))
|
||||
if (response.http !== 200 || !response.contentType.includes('application/pdf') || Buffer.from(response.bytes).subarray(0, 5).toString() !== '%PDF-')
|
||||
throw Error('Actual BOL endpoint did not return a PDF; preserve response metadata')
|
||||
writeFileSync(pdfPath, response.bytes, { flag: 'wx' })
|
||||
const result = JSON.parse(execFileSync(config.pdfPython, ['-c',
|
||||
'import json,sys; from pypdf import PdfReader; r=PdfReader(sys.argv[1]); print(json.dumps({"pages":[p.extract_text() for p in r.pages]}))', pdfPath],
|
||||
{ encoding: 'utf8', timeout: 30000 }))
|
||||
writeFileSync(join(evidence, 'bol-pages.json'), JSON.stringify(result, null, 2), { flag: 'wx' })
|
||||
context.createdResources.dispatchWorkflowBol.pdf = { path: 'evidence/BOL.pdf', ...record, pageCount: result.pages.length }
|
||||
await context.checkpoint(); return result.pages
|
||||
} })
|
||||
}
|
||||
return (config.scenario === 'reviews' ? verifyReviewExtension : config.scenario === 'competition' ? verifyCompetitionExtension : verifyDualDestination)(...args)
|
||||
},
|
||||
finish: async (...args) => { stable(); return finishFleetTrip(...args) },
|
||||
})
|
||||
return { success: result.success, error: result.error, runId: config.runId, requestCount: result.requestCount, summary: result.summary,
|
||||
sessionsClosed: result.sessionsClosed, scenario: config.scenario, extensionStatus: result.createdResources?.dispatchWorkflowExtensions?.status,
|
||||
reviewStatus: result.createdResources?.dispatchWorkflowReviews?.status,
|
||||
competitionStatus: result.createdResources?.dispatchWorkflowCompetition?.status,
|
||||
bolStatus: result.createdResources?.dispatchWorkflowBol?.status,
|
||||
report: join(reportDirectory, 'report.json'), syntheticTestMaterials: true, hardwareVerified: false, physicalMeasurementVerified: false }
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
const result = await runDispatchExtensions(parseExtensionArguments(process.argv.slice(2)))
|
||||
console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1
|
||||
}
|
||||
+35
-16
@@ -12,27 +12,42 @@ $ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows -or $PSVersionTable.PSVersion.Major -lt 7) {
|
||||
throw 'local-check requires Windows and PowerShell 7 (pwsh).'
|
||||
}
|
||||
# 所有工作目录使用同一用户级锁;句柄随进程退出关闭,不依赖删除过期锁文件。
|
||||
# 所有工作目录共用两个用户级锁;首槽保留原锁名,运行中的旧入口仍计入容量。
|
||||
$stateDirectory = Join-Path $env:LOCALAPPDATA 'Chongchong/LocalChecks'
|
||||
[IO.Directory]::CreateDirectory($stateDirectory) | Out-Null
|
||||
$lockPath = Join-Path $stateDirectory 'heavy.lock'
|
||||
$ownerPath = Join-Path $stateDirectory 'owner.json'
|
||||
$resultPath = Join-Path $stateDirectory 'last-result.json'
|
||||
function Read-Owner {
|
||||
try { return [IO.File]::ReadAllText($ownerPath) } catch { return 'Owner information is being updated.' }
|
||||
$slotDefinitions = @(
|
||||
[pscustomobject]@{ number = 1; lock = (Join-Path $stateDirectory 'heavy.lock'); owner = (Join-Path $stateDirectory 'owner.json'); result = (Join-Path $stateDirectory 'last-result.json') },
|
||||
[pscustomobject]@{ number = 2; lock = (Join-Path $stateDirectory 'heavy-2.lock'); owner = (Join-Path $stateDirectory 'owner-2.json'); result = (Join-Path $stateDirectory 'last-result-2.json') }
|
||||
)
|
||||
function Read-Owner($definition) {
|
||||
try { return [IO.File]::ReadAllText($definition.owner) } catch { return 'Owner information is being updated.' }
|
||||
}
|
||||
function Open-Slot {
|
||||
try { return [IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') }
|
||||
function Open-Slot($definition) {
|
||||
try { return [IO.File]::Open($definition.lock, 'OpenOrCreate', 'ReadWrite', 'None') }
|
||||
catch [IO.IOException] {
|
||||
if (($_.Exception.HResult -band 0xffff) -notin @(32, 33)) { throw }
|
||||
return $null
|
||||
}
|
||||
}
|
||||
function Open-AvailableSlot {
|
||||
foreach ($definition in $slotDefinitions) {
|
||||
$handle = Open-Slot $definition
|
||||
if ($handle) { return [pscustomobject]@{ definition = $definition; handle = $handle } }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
if ($Status) {
|
||||
$probe = Open-Slot
|
||||
if ($probe) { $probe.Dispose(); Write-Host 'IDLE: heavy check slot is free.' }
|
||||
else { Write-Host 'BUSY:'; Write-Host (Read-Owner) }
|
||||
if (Test-Path -LiteralPath $resultPath) { Write-Host 'Last result:'; Write-Host ([IO.File]::ReadAllText($resultPath)) }
|
||||
$busyCount = 0
|
||||
foreach ($definition in $slotDefinitions) {
|
||||
$probe = Open-Slot $definition
|
||||
if ($probe) { $probe.Dispose(); Write-Host "IDLE: slot $($definition.number)/2 is free." }
|
||||
else { $busyCount++; Write-Host "BUSY: slot $($definition.number)/2"; Write-Host (Read-Owner $definition) }
|
||||
if (Test-Path -LiteralPath $definition.result) {
|
||||
Write-Host "Last result for slot $($definition.number):"
|
||||
try { Write-Host ([IO.File]::ReadAllText($definition.result)) } catch { Write-Host 'Result is being updated.' }
|
||||
}
|
||||
}
|
||||
Write-Host "Capacity: $busyCount/2 slots occupied."
|
||||
exit 0
|
||||
}
|
||||
if (-not $Command -or -not $Command[0]) { throw 'Pass -Command as an array, for example @("pnpm", "typecheck").' }
|
||||
@@ -51,20 +66,24 @@ $owner = $null
|
||||
$exitCode = 1
|
||||
try {
|
||||
$nextNotice = 0
|
||||
while (-not ($slot = Open-Slot)) {
|
||||
while (-not ($lease = Open-AvailableSlot)) {
|
||||
if ($timer.Elapsed.TotalSeconds -ge $WaitSeconds) {
|
||||
Write-Host "QUEUE TIMEOUT after $WaitSeconds seconds. No check was started."
|
||||
exit 75
|
||||
}
|
||||
if ($timer.Elapsed.TotalSeconds -ge $nextNotice) {
|
||||
Write-Host "WAITING for the shared heavy check slot: $(Read-Owner)"
|
||||
$owners = @($slotDefinitions | ForEach-Object { "slot $($_.number)/2: $(Read-Owner $_)" }) -join ' | '
|
||||
Write-Host "WAITING for either shared heavy check slot: $owners"
|
||||
$nextNotice = $timer.Elapsed.TotalSeconds + 15
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
$owner = [ordered]@{ label = $Label; pid = $PID; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') }
|
||||
$slot = $lease.handle
|
||||
$ownerPath = $lease.definition.owner
|
||||
$resultPath = $lease.definition.result
|
||||
$owner = [ordered]@{ label = $Label; pid = $PID; slot = $lease.definition.number; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') }
|
||||
[IO.File]::WriteAllText($ownerPath, ($owner | ConvertTo-Json -Compress))
|
||||
Write-Host "RUNNING [$Label] in $directory (one shared slot; timeout ${TimeoutSeconds}s)"
|
||||
Write-Host "RUNNING [$Label] in $directory (shared slot $($lease.definition.number)/2; timeout ${TimeoutSeconds}s)"
|
||||
|
||||
# Job Object 在包装器被强制结束时也回收本次子进程树,不影响其他 Node/Java。
|
||||
if (-not ('LocalCheckJob' -as [type])) {
|
||||
|
||||
@@ -34,7 +34,7 @@ async function until(check, message) {
|
||||
assert.fail(message)
|
||||
}
|
||||
|
||||
test('local-check serializes different directories, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 90000 }, async t => {
|
||||
test('local-check bounds different directories to two slots, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 120000 }, async t => {
|
||||
const state = await mkdtemp(path.join(os.tmpdir(), 'local-check-test-'))
|
||||
const processes = []
|
||||
const start = (...args) => { const result = run(state, ...args); processes.push(result); return result }
|
||||
@@ -46,19 +46,31 @@ test('local-check serializes different directories, handles failures and recover
|
||||
assert.match(result.output, /"2","--max-old-space-size=2048"/)
|
||||
assert.match(result.output, /ActiveProcessorCount=2 -Xmx1536m/)
|
||||
})
|
||||
await t.test('a running check excludes another directory and queue timeout starts nothing', async () => {
|
||||
const first = start([process.execPath, '-e', 'console.log("HOLDER_READY");setTimeout(()=>{},4000)'])
|
||||
await until(() => first.output().includes('HOLDER_READY'), first.output())
|
||||
await t.test('two directories run together; a third times out or waits until one lease is released', async () => {
|
||||
const releaseFirst = path.join(state, 'release-first'), releaseSecond = path.join(state, 'release-second')
|
||||
const hold = (file, label) => `const fs=require('fs');console.log(${JSON.stringify(label)});const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(file)}))clearInterval(timer)},50)`
|
||||
const first = start([process.execPath, '-e', hold(releaseFirst, 'FIRST_READY')], { timeout: 30 })
|
||||
await until(() => first.output().includes('FIRST_READY'), first.output())
|
||||
const second = start([process.execPath, '-e', hold(releaseSecond, 'SECOND_READY')], { cwd: path.dirname(launcher), timeout: 30 })
|
||||
await until(() => second.output().includes('SECOND_READY'), second.output())
|
||||
assert.equal(first.child.exitCode, null)
|
||||
const owners = await Promise.all(['owner.json', 'owner-2.json'].map(name => readFile(path.join(state, 'Chongchong', 'LocalChecks', name), 'utf8').then(JSON.parse)))
|
||||
assert.deepEqual(owners.map(owner => owner.slot).sort(), [1, 2])
|
||||
assert.equal(new Set(owners.map(owner => owner.directory)).size, 2)
|
||||
const timedOut = await start([process.execPath, '-e', 'console.log("SHOULD_NOT_RUN")'], { wait: 0, cwd: path.dirname(launcher) }).done
|
||||
assert.equal(timedOut.code, 75, timedOut.output)
|
||||
assert.doesNotMatch(timedOut.output, /SHOULD_NOT_RUN/)
|
||||
const second = start([process.execPath, '-e', 'console.log("SECOND_READY")'], { cwd: path.dirname(launcher) })
|
||||
await until(() => second.output().includes('WAITING'), second.output())
|
||||
assert.doesNotMatch(second.output(), /SECOND_READY/)
|
||||
const third = start([process.execPath, '-e', 'console.log("THIRD_READY")'], { cwd: state })
|
||||
await until(() => third.output().includes('WAITING'), third.output())
|
||||
assert.doesNotMatch(third.output(), /THIRD_READY/)
|
||||
await writeFile(releaseFirst, '')
|
||||
assert.equal((await first.done).code, 0)
|
||||
const result = await second.done
|
||||
const result = await third.done
|
||||
assert.equal(result.code, 0, result.output)
|
||||
assert.match(result.output, /SECOND_READY/)
|
||||
assert.match(result.output, /THIRD_READY/)
|
||||
assert.equal(second.child.exitCode, null)
|
||||
await writeFile(releaseSecond, '')
|
||||
assert.equal((await second.done).code, 0)
|
||||
})
|
||||
await t.test('execution timeout kills descendants and frees the slot', async () => {
|
||||
const pidFile = path.join(state, 'descendant.pid')
|
||||
|
||||
Reference in New Issue
Block a user