feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution
This commit is contained in:
@@ -1,44 +1,275 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
|
||||
import { installRetainedJournal, retainedApi, saveRetainedReport } from './mixed-container-outbound-test-env.mjs'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
const platformUser = process.env.ADMIN_TEST_USERNAME
|
||||
const platformPassword = process.env.ADMIN_TEST_PASSWORD
|
||||
const pdaUser = process.env.PDA_TEST_USERNAME
|
||||
const pdaPassword = process.env.PDA_TEST_PASSWORD
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
||||
const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID
|
||||
const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN
|
||||
const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN
|
||||
const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN
|
||||
const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json'
|
||||
const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01')
|
||||
if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount')
|
||||
|
||||
for (const [name, value] of Object.entries({ customerId, warehouseId })) {
|
||||
if (!value) throw new Error(`Missing environment variable: ${name}`)
|
||||
const MODULE = 'overseas-relabel-producer'
|
||||
const DISPATCH = '/api/platform/v1/last-leg/dispatches'
|
||||
const id = value => {
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Relabel ID lost precision')
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Error('Missing platform credentials or token')
|
||||
if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials')
|
||||
if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token')
|
||||
|
||||
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false }
|
||||
function key(value) { return `${marker}-${value}`.slice(0, 200) }
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
async function api(base, token, method, path, body, headers = {}) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers },
|
||||
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') {
|
||||
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
const exactVersion = value => {
|
||||
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw Error('Authoritative relabel version is missing or imprecise')
|
||||
return value
|
||||
}
|
||||
const quantity = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : null
|
||||
const sorted = values => [...new Set(values)].sort()
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
||||
const canonical = value => JSON.stringify(normalize(value))
|
||||
function normalize(value) {
|
||||
if (Array.isArray(value)) return value.map(normalize)
|
||||
return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value
|
||||
}
|
||||
function requireFact(condition, message) { if (!condition) throw Error(message) }
|
||||
function producerResponsePresent(path, data) {
|
||||
if (data == null || typeof data !== 'object') return false
|
||||
if (path === DISPATCH) return data.dispatchNo && data.action === 'CREATE' && data.recordVersion != null && data.result?.dispatchNo === data.dispatchNo
|
||||
if (path.endsWith('/photos/files')) return /^[1-9]\d*$/.test(id(data.fileId))
|
||||
if (path.endsWith('/pallets/resolve')) return data.palletCode && /^[1-9]\d*$/.test(id(data.palletId)) && data.totalPackages != null
|
||||
if (path.endsWith('/relabel-pallet-label')) return data.palletCode && /^[1-9]\d*$/.test(id(data.palletId))
|
||||
if (path.includes('/relabel-tasks/')) return data.relabelNo && data.version != null && Array.isArray(data.items)
|
||||
if (path.includes('/relabel-applications')) return data.application?.relabelNo && Array.isArray(data.items)
|
||||
return true
|
||||
}
|
||||
export function installRelabelJournal(context, report, save) {
|
||||
installRetainedJournal(context, report, save)
|
||||
const request = context.request.bind(context), upload = context.requestMultipart.bind(context)
|
||||
const verify = async (method, path, send) => {
|
||||
if (method !== 'GET' && report.requests.some(row => ['SENDING', 'UNKNOWN'].includes(row.status))) throw Error('Unknown producer write outcome: stop with original commands')
|
||||
const response = await send()
|
||||
if (method !== 'GET' && response.http === 200 && String(response.body?.code) === '200'
|
||||
&& !producerResponsePresent(path, response.body?.data)) {
|
||||
const entry = report.requests.at(-1)
|
||||
if (entry) entry.status = 'UNKNOWN'
|
||||
await context.checkpoint(); throw Error('Unknown producer write result: native business response missing')
|
||||
}
|
||||
return response
|
||||
}
|
||||
return value.data
|
||||
context.request = (method, path, body, token, headers) => verify(method, path, () => request(method, path, body, token, headers))
|
||||
context.requestMultipart = (method, path, file, token, headers) => verify(method, path, () => upload(method, path, file, token, headers))
|
||||
return context
|
||||
}
|
||||
function validateLoadingEvidence(value, marker, dispatchNo, targets) {
|
||||
const physicalIds = sorted(targets.map(row => id(row.palletId)))
|
||||
requireFact(value?.environment === 'TEST' && value.marker === marker && typeof value.dispatchNo === 'string' && value.dispatchNo.trim() && value.dispatchNo === dispatchNo && Array.isArray(value.pallets)
|
||||
&& same(sorted(value.pallets.map(row => id(row.warehousePalletId))), physicalIds)
|
||||
&& value.pallets.length === physicalIds.length, 'Loading evidence belongs to another marker/dispatch/physical pallet')
|
||||
for (const row of value.pallets) {
|
||||
requireFact(Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0 && Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0,
|
||||
'Actual pallet spaces and physical stop index are required')
|
||||
for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) {
|
||||
requireFact(/^(0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number(row[field]) > 0
|
||||
&& row.evidence?.some(e => e.actual === true && e.field === field && e.sourceType && e.sourceBusinessNo
|
||||
&& Number.isFinite(Date.parse(e.observedAt)) && Number(e.value) === Number(row[field])), 'Actual physical loading field evidence is missing')
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
export function selectExplicitRelabelSource(candidates, config) {
|
||||
requireFact(config.sourceBalanceId && config.sourcePositionId, 'Explicit source balance and position are required; no historical inventory fallback')
|
||||
const matches = candidates.filter(row => id(row.inventoryBalanceId) === id(config.sourceBalanceId)
|
||||
&& id(row.overseasWarehouseId) === id(config.warehouseId)
|
||||
&& (!config.inboundCode || row.inboundCode === config.inboundCode)
|
||||
&& row.positions?.some(position => id(position.positionId) === id(config.sourcePositionId)))
|
||||
requireFact(matches.length === 1, 'Explicit relabel source is absent or ambiguous in this customer warehouse')
|
||||
const source = matches[0], positions = source.positions.filter(row => id(row.positionId) === id(config.sourcePositionId))
|
||||
requireFact(positions.length === 1 && String(source.inboundOrderNo || '').startsWith('DS-') && source.destinationFbaWarehouseCode
|
||||
&& quantity(source.availablePackages) >= 5 && quantity(positions[0].availablePackages) >= 5, 'Explicit source requires original DS inventory and five real available cartons')
|
||||
return { source, sourcePosition: positions[0] }
|
||||
}
|
||||
function scenarioCargo(scenario, rows, customerId, warehouseId) {
|
||||
const targets = sorted(scenario.targets.map(target => id(target.palletId)))
|
||||
const expected = scenario.items.reduce((sum, item) => sum + Number(item.notifiedPackageCount), 0)
|
||||
requireFact(rows.length === targets.length && same(sorted(rows.map(row => id(row.selection?.warehousePalletId))), targets)
|
||||
&& rows.reduce((sum, row) => sum + Number(row.availablePackages), 0) === expected, 'Native relabel candidates do not exactly cover original targets and carton quantity')
|
||||
const cargo = rows.map(row => {
|
||||
const selection = row.selection
|
||||
requireFact(selection?.sourceType === 'RELABEL' && selection.relabelNo === scenario.relabelNo
|
||||
&& selection.planPalletId == null && selection.returnReceiptId == null
|
||||
&& /^[1-9]\d*$/.test(id(selection.warehousePalletId))
|
||||
&& row.sourceKey === `RELABEL:${scenario.relabelNo}:${id(selection.warehousePalletId)}`
|
||||
&& /^[a-f\d]{64}$/i.test(row.sourceVersion || '') && row.sourceVersion === selection.sourceVersion
|
||||
&& id(row.customerId) === id(customerId) && id(row.warehouseId) === id(warehouseId)
|
||||
&& quantity(row.availablePackages) > 0 && row.destination?.type === 'FBA'
|
||||
&& row.destination.warehouseCode === scenario.destinationFbaWarehouseCode
|
||||
&& Array.isArray(row.blockReasons) && !row.blockReasons.length && Array.isArray(row.appointmentBatches) && !row.appointmentBatches.length,
|
||||
'Incomplete native RELABEL selector, warehouse/customer/destination or source version')
|
||||
requireFact(!Object.hasOwn(row, 'cartonMarks') && !Object.hasOwn(row, 'relabelCartonMarks'), 'Native candidates must not expose legacy carton mark projections')
|
||||
return { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: scenario.relabelNo,
|
||||
warehousePalletId: id(selection.warehousePalletId), sourceVersion: selection.sourceVersion }
|
||||
})
|
||||
return cargo
|
||||
}
|
||||
|
||||
/** The complete server group is the selection unit, including the whole application closure. */
|
||||
export function nativeRelabelCreateBody(marker, scenarios, rows, customerId, warehouseId) {
|
||||
requireFact(Array.isArray(scenarios) && scenarios.length > 0 && rows.length > 0
|
||||
&& new Set(scenarios.map(s => s.relabelNo)).size === scenarios.length, 'Owned relabel scenarios are missing or duplicate')
|
||||
const group = rows[0].selectionGroup
|
||||
requireFact(typeof group?.groupKey === 'string' && group.groupKey.trim() && typeof group.groupVersion === 'string' && group.groupVersion.trim()
|
||||
&& group.complete === true && Array.isArray(group.blockReasons) && !group.blockReasons.length
|
||||
&& Array.isArray(group.sources) && group.sources.length && Array.isArray(group.pallets) && group.pallets.length,
|
||||
'Authoritative complete selectionGroup and groupVersion are required')
|
||||
requireFact(rows.every(row => canonical(row.selectionGroup) === canonical(group)), 'Selection group version or membership drift')
|
||||
requireFact(new Set(rows.map(row => row.sourceKey)).size === rows.length && group.sources.length === rows.length
|
||||
&& new Set(group.sources.map(source => source.sourceKey)).size === group.sources.length
|
||||
&& same(sorted(group.sources.map(source => source.sourceKey)), sorted(rows.map(row => row.sourceKey))), 'Selection group logical sources are incomplete or duplicate')
|
||||
const cargo = scenarios.flatMap(scenario => scenarioCargo(scenario, rows.filter(row => row.selection?.relabelNo === scenario.relabelNo), customerId, warehouseId))
|
||||
requireFact(cargo.length === rows.length, 'Selection group contains an unknown relabel business member')
|
||||
for (const source of group.sources) {
|
||||
const row = rows.find(value => value.sourceKey === source.sourceKey), scenario = scenarios.find(value => value.relabelNo === source.sourceBusinessNo)
|
||||
requireFact(scenario?.relabelVerified === true && row && source.sourceBusinessType && id(source.customerId) === id(customerId)
|
||||
&& source.sourceBusinessNo === row.selection.relabelNo && canonical(source.selection) === canonical(row.selection)
|
||||
&& quantity(source.packages) > 0 && quantity(source.packages) === quantity(row.availablePackages), 'Selection group business, selector version or source quantity drift')
|
||||
}
|
||||
requireFact(new Set(group.pallets.map(p => id(p.warehousePalletId))).size === group.pallets.length
|
||||
&& same(sorted(group.pallets.map(p => id(p.warehousePalletId))), sorted(cargo.map(c => id(c.warehousePalletId)))), 'Selection group physical pallet closure is incomplete or duplicate')
|
||||
for (const pallet of group.pallets) {
|
||||
const sources = group.sources.filter(source => id(source.selection.warehousePalletId) === id(pallet.warehousePalletId))
|
||||
requireFact(/^[1-9]\d*$/.test(id(pallet.warehousePalletId)) && pallet.palletNo && sources.length
|
||||
&& Array.isArray(pallet.sourceKeys) && pallet.sourceKeys.length === sources.length
|
||||
&& same(sorted(pallet.sourceKeys), sorted(sources.map(source => source.sourceKey)))
|
||||
&& quantity(pallet.packageCount) === sources.reduce((sum, source) => sum + quantity(source.packages), 0)
|
||||
&& rows.filter(row => id(row.selection.warehousePalletId) === id(pallet.warehousePalletId)).every(row => row.palletNo === pallet.palletNo),
|
||||
'Selection group physical source contributions or carton quantity drift')
|
||||
}
|
||||
return { idempotencyKey: createHash('sha256').update(`${marker}\n${group.groupKey}\n${group.groupVersion}\nCREATE`).digest('hex'), warehouseId: id(warehouseId),
|
||||
vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${marker}-${scenarios.map(s => s.name).join('+')}`, cargo, batchAllocations: [] }
|
||||
}
|
||||
|
||||
export function nativeRelabelGroups(scenarios, rows, customerId, warehouseId) {
|
||||
const groups = new Map()
|
||||
for (const scenario of scenarios) {
|
||||
requireFact(scenario.relabelVerified === true && scenario.relabelCartonMarks?.length === scenario.expectedCartons
|
||||
&& new Set(scenario.relabelCartonMarks).size === scenario.relabelCartonMarks.length
|
||||
&& scenario.relabelCartonMarks.every(mark => typeof mark === 'string' && mark.trim() && !scenario.sourceCartonMarks.includes(mark)), 'Original/new relabel carton mark evidence is incomplete or overlaps')
|
||||
const candidates = rows.filter(row => row.selection?.sourceType === 'RELABEL' && row.selection.relabelNo === scenario.relabelNo)
|
||||
scenarioCargo(scenario, candidates, customerId, warehouseId)
|
||||
requireFact(candidates.every(row => row.selectionGroup?.groupKey) && new Set(candidates.map(row => row.selectionGroup.groupKey)).size === 1,
|
||||
'Whole relabel application must belong to one authoritative complete selection group')
|
||||
const key = candidates[0].selectionGroup.groupKey
|
||||
if (!groups.has(key)) groups.set(key, { scenarios: [], selectionGroup: candidates[0].selectionGroup })
|
||||
groups.get(key).scenarios.push(scenario)
|
||||
scenario.dispatchCandidates = candidates
|
||||
}
|
||||
const physicalOwners = new Map(), result = []
|
||||
for (const entry of groups.values()) {
|
||||
const keys = entry.selectionGroup.sources?.map(source => source.sourceKey) || []
|
||||
entry.candidates = rows.filter(row => keys.includes(row.sourceKey))
|
||||
entry.createBody = nativeRelabelCreateBody('PREFLIGHT', entry.scenarios, entry.candidates, customerId, warehouseId)
|
||||
for (const pallet of entry.selectionGroup.pallets) {
|
||||
const key = id(pallet.warehousePalletId)
|
||||
requireFact(!physicalOwners.has(key), 'One physical pallet cannot be split across selection groups')
|
||||
physicalOwners.set(key, entry.selectionGroup.groupKey)
|
||||
}
|
||||
result.push(entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Compare physical values once, retaining per-scenario raw receipts without adding whole-pallet weights.
|
||||
export function relabelGroupMeasurements(scenarios, group) {
|
||||
const values = new Map(), fields = ['palletCode', 'packages', 'stockVersion', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm']
|
||||
for (const scenario of scenarios) for (const measured of scenario.physicalMeasurements || []) {
|
||||
const palletId = id(measured.palletId), pallet = group.pallets.find(p => id(p.warehousePalletId) === palletId)
|
||||
if (!pallet) continue
|
||||
requireFact(measured.palletCode === pallet.palletNo && quantity(measured.packages) === quantity(pallet.packageCount), 'Physical measurement does not cover the complete shared pallet')
|
||||
const old = values.get(palletId)
|
||||
if (old) requireFact(fields.every(field => canonical(old[field]) === canonical(measured[field])), 'Shared physical measurement versions or values conflict')
|
||||
else values.set(palletId, structuredClone(measured))
|
||||
}
|
||||
return [...values.values()]
|
||||
}
|
||||
|
||||
function groupLoadingEvidence(config, report, scenarios, group, dispatchNo) {
|
||||
const targets = group.pallets.map(p => ({ palletId: id(p.warehousePalletId) })), inputs = scenarios.map(s => config.loadingEvidenceByScenario?.[s.name]).filter(Boolean)
|
||||
if (!inputs.length) return undefined
|
||||
const validated = inputs.map(value => validateLoadingEvidence(value, report.marker, dispatchNo || value.dispatchNo, targets))
|
||||
const physical = value => [...value.pallets].sort((a, b) => id(a.warehousePalletId).localeCompare(id(b.warehousePalletId))).map(p => ({ warehousePalletId: id(p.warehousePalletId),
|
||||
palletSpaces: p.palletSpaces, weightKg: String(Number(p.weightKg)), volumeCbm: String(Number(p.volumeCbm)), deliveryIndex: p.deliveryIndex }))
|
||||
requireFact(validated.every(value => value.dispatchNo === validated[0].dispatchNo && canonical(physical(value)) === canonical(physical(validated[0]))), 'Shared scenario loading receipts must name the same dispatch and identical physical measurements')
|
||||
return structuredClone(validated[0])
|
||||
}
|
||||
|
||||
/** Read the complete candidate universe and validate every owned group before the first CREATE. */
|
||||
export async function publishNativeRelabelDispatches(context, report, config) {
|
||||
const scenarios = report.scenarios.filter(row => row.operationType === 'CARTON_AND_SKU')
|
||||
report.blockers ||= []
|
||||
const block = (scenario, code, evidence) => {
|
||||
scenario.dispatchStatus = 'BLOCKED'; scenario.nativeDispatchBlocker = { code, ...evidence }
|
||||
report.blockers.push({ scenario: scenario.name, code, ...evidence })
|
||||
context.addCoverageGap?.(MODULE, scenario.name, 'Native relabel dispatch', code, 'physical-source-blocked', { deploymentBlocker: true })
|
||||
context.addStep?.(MODULE, `${scenario.name} native dispatch blocked`, null, false, code, { deploymentBlocker: true })
|
||||
}
|
||||
const page = await retainedApi(context, 'GET', `${DISPATCH}/candidates?${new URLSearchParams({ warehouseId: id(config.warehouseId), sourceStage: 'RELABEL' })}`)
|
||||
requireFact(page?.complete === true && Array.isArray(page.candidates), 'Native candidate list is incomplete')
|
||||
for (const scenario of scenarios) {
|
||||
scenario.sourceAfter = Number((await retainedApi(context, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: report.sourcePosition.palletNo }, config.pdaToken)).totalPackages)
|
||||
scenario.targetPallets = scenario.targets.map(target => ({ palletId: id(target.palletId), palletCode: target.palletCode }))
|
||||
scenario.physicalMeasurements = await retainedApi(context, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-measurements', { relabelNo: scenario.relabelNo }, config.pdaToken)
|
||||
await context.checkpoint()
|
||||
}
|
||||
let groups
|
||||
try {
|
||||
groups = nativeRelabelGroups(scenarios, page.candidates, config.customerId, config.warehouseId)
|
||||
for (const group of groups) {
|
||||
group.physicalMeasurements = relabelGroupMeasurements(group.scenarios, group.selectionGroup)
|
||||
groupLoadingEvidence(config, report, group.scenarios, group.selectionGroup)
|
||||
}
|
||||
} catch (error) {
|
||||
report.nativeSelectionFailure = { message: error.message, candidates: page.candidates }
|
||||
for (const scenario of scenarios) block(scenario, 'NATIVE_SELECTION_GROUP_NOT_VERIFIED', { reason: error.message })
|
||||
await context.checkpoint(); throw error
|
||||
}
|
||||
for (const group of groups) {
|
||||
const members = group.scenarios
|
||||
try {
|
||||
const createBody = nativeRelabelCreateBody(report.marker, members, group.candidates, config.customerId, config.warehouseId)
|
||||
const native = { marker: report.marker, customerId: id(config.customerId), warehouseId: id(config.warehouseId), createBody, physicalMeasurements: group.physicalMeasurements }
|
||||
for (const scenario of members) {
|
||||
scenario.nativeDispatch = native; scenario.nativeCreateBody = createBody; scenario.selectionGroup = group.selectionGroup
|
||||
scenario.sourceContributions = group.selectionGroup.sources.filter(source => source.sourceBusinessNo === scenario.relabelNo)
|
||||
}
|
||||
await context.checkpoint()
|
||||
const createResult = await retainedApi(context, 'POST', DISPATCH, createBody)
|
||||
Object.assign(native, { createResult, dispatchNo: createResult?.dispatchNo }); await context.checkpoint()
|
||||
requireFact(createResult?.action === 'CREATE' && createResult.dispatchNo && createResult.status === 'ACTIVE'
|
||||
&& createResult.stage === 'BEFORE_LOADING', 'Native CREATE did not return original active vehicle identity')
|
||||
exactVersion(createResult.recordVersion)
|
||||
const replay = await retainedApi(context, 'POST', DISPATCH, createBody)
|
||||
native.createReplay = replay; await context.checkpoint()
|
||||
requireFact(replay.replayed === true && replay.dispatchNo === createResult.dispatchNo
|
||||
&& String(replay.recordVersion) === String(createResult.recordVersion) && same(replay.result, createResult.result), 'Native CREATE replay changed original result')
|
||||
const detail = await retainedApi(context, 'GET', `${DISPATCH}/${encodeURIComponent(createResult.dispatchNo)}`)
|
||||
const sourceKeys = group.candidates.map(row => row.sourceKey)
|
||||
requireFact(detail?.dispatchNo === createResult.dispatchNo && detail.status === 'ACTIVE' && detail.stage === 'BEFORE_LOADING'
|
||||
&& quantity(detail.counts?.packageCount) === group.selectionGroup.sources.reduce((sum, source) => sum + quantity(source.packages), 0)
|
||||
&& quantity(detail.counts?.palletCount) === group.selectionGroup.pallets.length
|
||||
&& id(exactVersion(detail.recordVersion)) === id(createResult.recordVersion)
|
||||
&& detail.orderDetails?.length >= sourceKeys.length
|
||||
&& same(sorted(detail.orderDetails?.map(row => row.sourceKey) || []), sorted(sourceKeys))
|
||||
&& detail.orderDetails.every(row => group.selectionGroup.sources.some(source => source.sourceKey === row.sourceKey && id(source.customerId) === id(row.customerId)
|
||||
&& source.sourceBusinessNo === row.sourceBusinessNo && source.sourceBusinessType === row.sourceBusinessType)), 'Native Detail source/business/customer/carton identity drift')
|
||||
native.detail = detail
|
||||
const measurements = groupLoadingEvidence(config, report, members, group.selectionGroup, createResult.dispatchNo)
|
||||
if (measurements) native.loadingMeasurements = measurements
|
||||
for (const scenario of members) {
|
||||
scenario.dispatchNo = createResult.dispatchNo; scenario.dispatchStatus = detail.status
|
||||
if (!measurements) block(scenario, 'ACTUAL_LOADING_MEASUREMENT_REQUIRED', { fields: ['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'],
|
||||
dispatchNo: createResult.dispatchNo, evidenceKind: 'PHYSICAL_MEASUREMENTS_NOT_FABRICATED' })
|
||||
}
|
||||
await context.checkpoint()
|
||||
} catch (error) {
|
||||
if (report.requests?.some(row => ['SENDING', 'UNKNOWN'].includes(row.status))) throw error
|
||||
for (const scenario of members) block(scenario, 'NATIVE_DISPATCH_NOT_VERIFIED', { candidates: scenario.dispatchCandidates, sourceBeforePackages: scenario.sourceBefore,
|
||||
sourceAfterPackages: scenario.sourceAfter, reason: error.message, evidenceKind: 'RETAINED_ORIGINAL_REQUEST_AND_RESPONSE' })
|
||||
await context.checkpoint()
|
||||
}
|
||||
}
|
||||
return scenarios
|
||||
}
|
||||
async function poll(action, predicate, attempts = 40) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
@@ -50,183 +281,226 @@ async function poll(action, predicate, attempts = 40) {
|
||||
}
|
||||
function assert(condition, message) { if (!condition) throw new Error(message) }
|
||||
|
||||
const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword })
|
||||
const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword })
|
||||
const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` })
|
||||
const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
const platformToken = directPlatformToken || platformLogin.overseasAccessToken
|
||||
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
|
||||
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
|
||||
assert(platformToken && merchantToken && pdaToken, 'Missing overseas token')
|
||||
|
||||
const candidates = await api(overseas, merchantToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`)
|
||||
const source = candidates.find((item) => String(item.inboundOrderNo || '').startsWith('DS-')
|
||||
&& Number(item.availablePackages) >= 5
|
||||
&& item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))
|
||||
assert(source, 'No overseas inventory source with at least five available cartons')
|
||||
const sourcePosition = source.positions.find((position) => Number(position.availablePackages) >= 5)
|
||||
export async function runRelabelProducer(context, config, report) {
|
||||
const { customerId, warehouseId, merchantToken, pdaToken, chargeAmount } = config
|
||||
const platformToken = context.token, marker = report.marker
|
||||
const key = value => `${marker}-${value}`.slice(0, 200)
|
||||
const api = (token, method, path, body) => retainedApi(context, method, path, body, token)
|
||||
const candidates = await api(merchantToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`)
|
||||
const { source, sourcePosition } = selectExplicitRelabelSource(candidates, config)
|
||||
report.source = source; report.sourcePosition = sourcePosition
|
||||
await context.checkpoint()
|
||||
async function resolvePallet(palletCode) {
|
||||
return api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
|
||||
}
|
||||
|
||||
async function resolvePallet(palletCode) {
|
||||
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
|
||||
}
|
||||
|
||||
async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) {
|
||||
const suffix = `${name}-${Date.now()}`
|
||||
const items = Array.from({ length: cartonCount }, (_, index) => ({
|
||||
sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId),
|
||||
sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode,
|
||||
productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1,
|
||||
shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128),
|
||||
labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`,
|
||||
fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null,
|
||||
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
||||
operationType, skuItems: [{ skuCode: source.skuCode,
|
||||
newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }],
|
||||
}))
|
||||
const created = await api(overseas, platformToken, 'POST',
|
||||
`/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, {
|
||||
customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId,
|
||||
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
||||
contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}),
|
||||
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`),
|
||||
deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK',
|
||||
printShippingLabel: false, items,
|
||||
})
|
||||
const relabelNo = created.application.relabelNo
|
||||
if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/relabel-applications/${relabelNo}`),
|
||||
(detail) => detail.application.walletStatus === 'FROZEN')
|
||||
let detail = await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) })
|
||||
for (const [index, item] of detail.items.entries()) {
|
||||
if (operationType === 'CARTON_AND_SKU') {
|
||||
detail = await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
|
||||
cartonMark: item.cartonMark, expectedVersion: Number(detail.version),
|
||||
idempotencyKey: key(`${name}-CARTON-${index}`),
|
||||
})
|
||||
const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId))
|
||||
assert(Number(stageItem?.cartonProcessedPackages) === 1
|
||||
&& Number(stageItem?.skuProcessedPackages) === 0,
|
||||
`${relabelNo} carton stage incorrectly locked or completed SKU stage`)
|
||||
async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) {
|
||||
const suffix = `${name}-${marker}`
|
||||
const items = Array.from({ length: cartonCount }, (_, index) => ({
|
||||
sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId),
|
||||
sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode,
|
||||
productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1,
|
||||
shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128),
|
||||
labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`,
|
||||
fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null,
|
||||
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
||||
operationType, skuItems: [{ skuCode: source.skuCode,
|
||||
newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }],
|
||||
}))
|
||||
const scenario = { name, operationType, expectedCartons: cartonCount, items: [], targets: [], requestItems: items,
|
||||
sourceCartonMarks: items.map(item => item.cartonMark), relabelVerified: false }
|
||||
report.scenarios.push(scenario)
|
||||
const created = await api(platformToken, 'POST',
|
||||
`/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, {
|
||||
customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId,
|
||||
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
||||
contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}),
|
||||
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`),
|
||||
deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK',
|
||||
printShippingLabel: false, items,
|
||||
})
|
||||
const relabelNo = created.application.relabelNo
|
||||
scenario.relabelNo = relabelNo; scenario.createResult = created
|
||||
assert(created.application.overseasWarehouseId === warehouseId, 'Created relabel belongs to a different warehouse')
|
||||
await context.checkpoint()
|
||||
if (chargeAmount > 0) await poll(() => api(platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/relabel-applications/${relabelNo}`),
|
||||
(detail) => detail.application.walletStatus === 'FROZEN')
|
||||
let detail = await api(pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) })
|
||||
for (const [index, item] of detail.items.entries()) {
|
||||
if (operationType === 'CARTON_AND_SKU') {
|
||||
const cartonBody = { cartonMark: item.cartonMark, expectedVersion: exactVersion(detail.version), idempotencyKey: key(`${name}-CARTON-${index}`) }
|
||||
detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, cartonBody)
|
||||
const cartonReplay = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, cartonBody)
|
||||
assert(String(cartonReplay.version) === String(detail.version) && same(cartonReplay.items, detail.items), 'Original carton scan replay changed quantities/version')
|
||||
const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId))
|
||||
assert(Number(stageItem?.cartonProcessedPackages) === 1
|
||||
&& Number(stageItem?.skuProcessedPackages) === 0,
|
||||
`${relabelNo} carton stage incorrectly locked or completed SKU stage`)
|
||||
}
|
||||
const scanBody = {
|
||||
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null,
|
||||
skuVerifications: (item.skuItems || []).map((sku) => ({
|
||||
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
|
||||
})),
|
||||
expectedVersion: exactVersion(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
|
||||
}
|
||||
detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, scanBody)
|
||||
const scanReplay = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, scanBody)
|
||||
assert(String(scanReplay.version) === String(detail.version) && same(scanReplay.items, detail.items), 'Original SKU verification replay changed quantities/version')
|
||||
}
|
||||
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
|
||||
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null,
|
||||
skuVerifications: (item.skuItems || []).map((sku) => ({
|
||||
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
|
||||
})),
|
||||
expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
|
||||
const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
||||
Object.assign(scenario, { items: detail.items, sourceBefore, sourceCartonMarks: items.map(item => item.cartonMark), requestItems: items })
|
||||
if (prepare) await prepare(scenario)
|
||||
// Valid synthetic PNG for API validation; no physical warehouse photograph is claimed.
|
||||
const photoResponse = await context.requestMultipart('POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, {
|
||||
name: `${marker}-${name}.png`, type: 'image/png',
|
||||
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'),
|
||||
}, pdaToken)
|
||||
const photo = context.apiSuccess('overseas-relabel-producer', 'Upload synthetic relabel PNG', photoResponse)
|
||||
assert(photoResponse.http === 200 && String(photoResponse.body?.code) === '200' && photo?.fileId, 'Synthetic photo upload failed')
|
||||
scenario.photo = { fileId: id(photo.fileId), evidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK' }
|
||||
detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
|
||||
photoFileIds: [id(photo.fileId)], expectedVersion: exactVersion(detail.version),
|
||||
idempotencyKey: key(`${name}-COMPLETE`), remark: marker,
|
||||
})
|
||||
assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`)
|
||||
const confirmed = await api(merchantToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
|
||||
idempotencyKey: key(`${name}-CONFIRM`), remark: marker,
|
||||
})
|
||||
const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND'
|
||||
assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`)
|
||||
assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode),
|
||||
`${relabelNo} did not preserve the destination warehouse`)
|
||||
Object.assign(scenario, { items: confirmed.items, confirmedResult: confirmed, relabelVerified: true,
|
||||
relabelCartonMarks: operationType === 'CARTON_AND_SKU' ? confirmed.items.map(item => item.fbaCartonMark) : [] })
|
||||
scenario.sourceAfterRelabel = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
||||
await context.checkpoint()
|
||||
return scenario
|
||||
}
|
||||
|
||||
async function printPallet(relabelNo, suffix) {
|
||||
return api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
|
||||
relabelNo, idempotencyKey: key(`PRINT-${suffix}`),
|
||||
})
|
||||
}
|
||||
const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
||||
const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore }
|
||||
if (prepare) await prepare(scenario)
|
||||
const form = new FormData()
|
||||
// Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work.
|
||||
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`)
|
||||
const photo = await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form)
|
||||
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
|
||||
photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version),
|
||||
idempotencyKey: key(`${name}-COMPLETE`), remark: marker,
|
||||
})
|
||||
assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`)
|
||||
const confirmed = await api(overseas, merchantToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
|
||||
idempotencyKey: key(`${name}-CONFIRM`), remark: marker,
|
||||
async function moveCarton(scenario, item, target, index) {
|
||||
const sourcePallet = await resolvePallet(sourcePosition.palletNo)
|
||||
const targetPallet = await resolvePallet(target.palletCode)
|
||||
const result = await api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
|
||||
idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo,
|
||||
source: { palletCode: sourcePallet.palletCode, expectedVersion: exactVersion(sourcePallet.palletVersion) },
|
||||
target: { palletCode: targetPallet.palletCode, expectedVersion: exactVersion(targetPallet.palletVersion) },
|
||||
cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`,
|
||||
})
|
||||
const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND'
|
||||
assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`)
|
||||
assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode),
|
||||
`${relabelNo} did not preserve the destination warehouse`)
|
||||
return { ...scenario, items: confirmed.items }
|
||||
}
|
||||
assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type')
|
||||
return result
|
||||
}
|
||||
|
||||
async function printPallet(relabelNo, suffix) {
|
||||
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
|
||||
relabelNo, idempotencyKey: key(`PRINT-${suffix}`),
|
||||
const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL')
|
||||
skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
||||
assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets')
|
||||
skuOnly.targetPallets = []
|
||||
skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode
|
||||
|
||||
|
||||
let sharedTarget
|
||||
const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => {
|
||||
sharedTarget = await printPallet(scenario.relabelNo, 'SHARED')
|
||||
const printReplay = await printPallet(scenario.relabelNo, 'SHARED')
|
||||
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
|
||||
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
|
||||
scenario.targets = [sharedTarget]
|
||||
})
|
||||
}
|
||||
async function moveCarton(scenario, item, target, index) {
|
||||
const sourcePallet = await resolvePallet(sourcePosition.palletNo)
|
||||
const targetPallet = await resolvePallet(target.palletCode)
|
||||
const result = await api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
|
||||
idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo,
|
||||
source: { palletCode: sourcePallet.palletCode, expectedVersion: Number(sourcePallet.palletVersion) },
|
||||
target: { palletCode: targetPallet.palletCode, expectedVersion: Number(targetPallet.palletVersion) },
|
||||
cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`,
|
||||
|
||||
const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
||||
const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => {
|
||||
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
|
||||
scenario.targets = [sharedTarget]
|
||||
})
|
||||
assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type')
|
||||
return result
|
||||
}
|
||||
const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
||||
assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase')
|
||||
merged.targets = [sharedTarget]
|
||||
report.sharedMerge = { palletId: id(sharedTarget.palletId), palletCode: sharedTarget.palletCode, beforePackages: sharedBefore, afterPackages: sharedAfter,
|
||||
sourceScenarios: [{ name: fresh.name, cartons: fresh.expectedCartons }, { name: merged.name, cartons: merged.expectedCartons }] }
|
||||
|
||||
const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL')
|
||||
skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
||||
assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets')
|
||||
skuOnly.targetPallets = []
|
||||
skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode
|
||||
delete skuOnly.items
|
||||
|
||||
let sharedTarget
|
||||
const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => {
|
||||
sharedTarget = await printPallet(scenario.relabelNo, 'SHARED')
|
||||
const printReplay = await printPallet(scenario.relabelNo, 'SHARED')
|
||||
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
|
||||
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
|
||||
scenario.targets = [sharedTarget]
|
||||
})
|
||||
|
||||
const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
||||
const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => {
|
||||
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
|
||||
scenario.targets = [sharedTarget]
|
||||
})
|
||||
const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
||||
assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase')
|
||||
merged.targets = [sharedTarget]
|
||||
|
||||
const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => {
|
||||
const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A')
|
||||
const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B')
|
||||
await moveCarton(scenario, scenario.items[0], splitA, 0)
|
||||
await moveCarton(scenario, scenario.items[1], splitB, 1)
|
||||
scenario.targets = [splitA, splitB]
|
||||
})
|
||||
|
||||
for (const scenario of [fresh, merged, split]) {
|
||||
const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
||||
scenario.sourceAfter = sourceAfter
|
||||
scenario.targetPallets = scenario.targets.map((target) => ({ palletId: id(target.palletId), palletCode: target.palletCode }))
|
||||
const candidateRows = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(scenario.relabelNo)}&sourceStage=RECEIVED`)
|
||||
const targetIds = new Set(scenario.targets.map((target) => id(target.palletId)))
|
||||
const rows = candidateRows.filter((row) => row.sourceStage === 'RELABEL' && targetIds.has(id(row.warehousePalletId)))
|
||||
const marks = rows.flatMap((row) => String(row.relabelCartonMarks || '').split(',').filter(Boolean))
|
||||
assert(rows.length === targetIds.size, `${scenario.relabelNo} missing smart-dispatch pallet candidate`)
|
||||
assert(rows.every((row) => !String(row.cartonMarks || '').trim()), `${scenario.relabelNo} exposed normal carton marks`)
|
||||
assert(scenario.items.every((item) => marks.includes(item.fbaCartonMark)), `${scenario.relabelNo} missing relabel carton mark`)
|
||||
const dispatch = await api(overseas, platformToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', {
|
||||
planPalletIds: rows.map((row) => id(row.warehousePalletId)), route: `${marker}-${scenario.name}`,
|
||||
relabelNo: scenario.relabelNo,
|
||||
const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => {
|
||||
const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A')
|
||||
const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B')
|
||||
await moveCarton(scenario, scenario.items[0], splitA, 0)
|
||||
await moveCarton(scenario, scenario.items[1], splitB, 1)
|
||||
scenario.targets = [splitA, splitB]
|
||||
})
|
||||
scenario.dispatchNo = dispatch.dispatchNo
|
||||
scenario.dispatchStatus = dispatch.status
|
||||
scenario.relabelCartonMarks = marks
|
||||
delete scenario.items
|
||||
delete scenario.targets
|
||||
|
||||
for (const scenario of [fresh, merged, split]) scenario.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode
|
||||
const ledger = await api(platformToken, 'GET', `/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`)
|
||||
const ledgerRows = ledger.records.filter(row => String(row.remark || '').includes(marker))
|
||||
assert(ledgerRows.some(row => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger')
|
||||
assert(ledgerRows.some(row => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger')
|
||||
assert(ledgerRows.every(row => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason')
|
||||
report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map(row => Number(row.deltaPackages)), records: ledgerRows }
|
||||
report.relabelScenariosVerified = report.scenarios.length === 4 && report.scenarios.every(scenario => scenario.relabelVerified)
|
||||
await publishNativeRelabelDispatches(context, report, config)
|
||||
report.passed = report.relabelScenariosVerified && !report.blockers.length
|
||||
report.finalStatus = report.passed ? 'RELABEL_AND_NATIVE_DISPATCH_VERIFIED' : 'BLOCKED_NATIVE_DISPATCH_OR_PHYSICAL_LOADING_EVIDENCE'
|
||||
await context.checkpoint(); return report
|
||||
}
|
||||
|
||||
const ledger = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`)
|
||||
const ledgerRows = ledger.records.filter((row) => String(row.remark || '').includes(marker))
|
||||
assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger')
|
||||
assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger')
|
||||
assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason')
|
||||
report.scenarios = [skuOnly, fresh, merged, split]
|
||||
report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) }
|
||||
report.passed = true
|
||||
report.finishedAt = new Date().toISOString()
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
export async function mainRelabelProducer(env = process.env) {
|
||||
const options = parseArguments([]); options.modules = []
|
||||
const output = resolve(env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json')
|
||||
options.output = output; validateExecutionOptions(options)
|
||||
requireFact(options.environment === 'test', 'Relabel producer only supports explicit TEST')
|
||||
const customerId = id(env.OVERSEAS_TEST_CUSTOMER_ID), warehouseId = id(env.OVERSEAS_TEST_WAREHOUSE_ID)
|
||||
const sourceBalanceId = id(env.OVERSEAS_RELABEL_SOURCE_BALANCE_ID), sourcePositionId = id(env.OVERSEAS_RELABEL_SOURCE_POSITION_ID)
|
||||
requireFact(/^[1-9]\d*$/.test(customerId) && /^[1-9]\d*$/.test(warehouseId) && /^[1-9]\d*$/.test(sourceBalanceId) && /^[1-9]\d*$/.test(sourcePositionId),
|
||||
'Explicit customer, warehouse, original source balance and position IDs are required before HTTP')
|
||||
requireFact(!existsSync(output), 'Existing producer checkpoint must be reconciled with its original commands; do not overwrite it or regenerate a marker')
|
||||
const chargeAmount = Number(env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01')
|
||||
requireFact(Number.isFinite(chargeAmount) && chargeAmount >= 0, 'Invalid charge amount')
|
||||
const context = new AcceptanceRun(options), marker = env.OVERSEAS_RELABEL_MARKER || context.marker
|
||||
requireFact(/^[A-Za-z0-9_-]+$/.test(marker), 'Invalid explicit producer marker')
|
||||
const report = { marker, environment: 'TEST', startedAt: new Date().toISOString(), scope: 'RELABEL_PRODUCER_AND_NATIVE_DISPATCH_ONLY',
|
||||
chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], blockers: [], passed: false, requests: [] }
|
||||
const raw = async (method, path, body, token) => {
|
||||
const response = await context.request(method, path, body, token)
|
||||
requireFact(response.http === 200 && String(response.body?.code) === '200', 'Unified authentication failed')
|
||||
return response.body.data
|
||||
}
|
||||
try {
|
||||
// Session tickets and tokens remain in memory and never enter the business journal.
|
||||
const platform = await raw('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
|
||||
context.token = platform.accessToken
|
||||
requireFact(context.token && options.pdaUsername && options.pdaPassword, 'Unified platform and explicit PDA credentials required')
|
||||
const pda = await raw('POST', '/api/auth/login', { loginId: options.pdaUsername, password: options.pdaPassword }, '')
|
||||
let merchant
|
||||
if (options.merchantUsername && options.merchantPassword) merchant = await raw('POST', '/api/auth/login', { loginId: options.merchantUsername, password: options.merchantPassword }, '')
|
||||
else {
|
||||
const ticket = await raw('POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} TEST relabel producer` }, context.token)
|
||||
merchant = await raw('POST', '/api/auth/impersonations/exchange', { code: ticket.code }, '')
|
||||
}
|
||||
requireFact(pda.accessToken && merchant.accessToken, 'Unified PDA/customer accessToken required')
|
||||
const profile = await raw('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchant.accessToken)
|
||||
requireFact(id(profile.id) === customerId, 'Merchant session does not own the explicit customer')
|
||||
installRelabelJournal(context, report, () => saveRetainedReport(output, report)); context.marker = marker
|
||||
const loadingEvidenceByScenario = env.OVERSEAS_RELABEL_LOADING_EVIDENCE ? JSON.parse(readFileSync(resolve(env.OVERSEAS_RELABEL_LOADING_EVIDENCE), 'utf8')) : undefined
|
||||
await runRelabelProducer(context, { customerId, warehouseId, sourceBalanceId, sourcePositionId, inboundCode: env.OVERSEAS_RELABEL_SOURCE_INBOUND_CODE,
|
||||
merchantToken: merchant.accessToken, pdaToken: pda.accessToken, chargeAmount, loadingEvidenceByScenario }, report)
|
||||
} catch (error) {
|
||||
report.passed = false; report.finalStatus = report.requests.some(row => ['SENDING', 'UNKNOWN'].includes(row.status)) ? 'UNKNOWN_WRITE_STOPPED' : 'BLOCKED'
|
||||
report.failedStage = report.scenarios.at(-1)?.name || 'CUSTOMER_OR_EXPLICIT_SOURCE'; process.exitCode = 1
|
||||
} finally {
|
||||
report.checks = context.steps; report.finishedAt = new Date().toISOString()
|
||||
if (!report.passed || context.steps.some(step => step.passed === false)) process.exitCode = 1
|
||||
saveRetainedReport(output, report)
|
||||
console.log(JSON.stringify({ marker, finalStatus: report.finalStatus, passed: report.passed,
|
||||
relabelScenariosVerified: report.relabelScenariosVerified, scenarios: report.scenarios.map(scenario => ({ name: scenario.name,
|
||||
relabelNo: scenario.relabelNo, dispatchNo: scenario.dispatchNo, dispatchStatus: scenario.dispatchStatus })), report: output }))
|
||||
}
|
||||
return report
|
||||
}
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainRelabelProducer()
|
||||
|
||||
Reference in New Issue
Block a user