1254 lines
91 KiB
JavaScript
1254 lines
91 KiB
JavaScript
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
|
|
|
|
const MODULE = 'phase4-overseas-pda'
|
|
const SCENARIO = 'phase4-overseas-pda-container-receiving'
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
|
const OBSERVATION_OPTIONS = { scenario: SCENARIO, deploymentBlocker: false }
|
|
const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']
|
|
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
|
|
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
|
export function uniqueContainerProducer(item, index, all) {
|
|
return nonBlank(item?.containerNo)
|
|
&& all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index
|
|
}
|
|
export function crossContainerCartonProbe(producers, current) {
|
|
const currentCodes = new Set((current?.inbounds || []).map(item => id(item.inboundCode)))
|
|
if (!nonBlank(current?.containerNo) || !currentCodes.size) return null
|
|
for (const item of producers || []) {
|
|
if (!nonBlank(item?.containerNo) || item.containerNo === current?.containerNo
|
|
|| !longId(item?.containerBookingId) || !longId(item?.sourceOrderId)
|
|
|| !longId(item?.sourceInboundId) || item?.sourceStatus !== 'LOADED'
|
|
|| !nonBlank(item?.inboundCode) || currentCodes.has(item.inboundCode)) continue
|
|
const standardFba = item.destinationType === 'FBA' && item.fulfillmentType === 'STANDARD'
|
|
const marks = standardFba ? item.receivingCartonMarks
|
|
: (Array.isArray(item.cartonMarks) ? item.cartonMarks : [item.cartonMark])
|
|
const prefix = `${item.inboundCode}#PDA-CONTRACT-`
|
|
const cartonMark = (Array.isArray(marks) ? marks : []).map(id).find(value => nonBlank(value)
|
|
&& (!standardFba || (value.startsWith(prefix) && /^[1-9][0-9]*$/.test(value.slice(prefix.length)))))
|
|
if (cartonMark) return { cartonMark, containerNo:item.containerNo,
|
|
containerBookingId:id(item.containerBookingId), inboundCode:item.inboundCode,
|
|
sourceOrderId:id(item.sourceOrderId), sourceInboundId:id(item.sourceInboundId),
|
|
mode:standardFba ? 'FBA_RECEIVING_PREFIX' : 'PHYSICAL_CARTON' }
|
|
}
|
|
return null
|
|
}
|
|
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
|
function code(response) { return String(response?.body?.code ?? '') }
|
|
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
|
function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) }
|
|
function a0409Message(response, pattern) { return response?.http === 200 && code(response) === 'A0409' && traced(response) && pattern.test(String(response?.body?.message || '')) }
|
|
function a0404Message(response, pattern) { return response?.http === 200 && code(response) === 'A0404' && traced(response) && pattern.test(String(response?.body?.message || '')) }
|
|
function cartonResolveRejected(response) { return response?.http === 200 && ['A0400', 'A0404', 'A0409'].includes(code(response)) && traced(response) }
|
|
function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) }
|
|
function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) }
|
|
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
|
|
function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
|
|
function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
|
|
function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
|
|
function receivingSnapshot(data) {
|
|
return JSON.stringify({
|
|
containerNo: data?.containerNo, status: data?.status, version: data?.version,
|
|
totalPackages: data?.totalPackages, receivedPackages: data?.receivedPackages,
|
|
pendingPackages: data?.pendingPackages,
|
|
inbounds: (Array.isArray(data?.inbounds) ? data.inbounds : []).map((item) => ({
|
|
inboundCode: item?.inboundCode, destinationType: item?.destinationType,
|
|
totalPackages: item?.totalPackages, receivedPackages: item?.receivedPackages,
|
|
pendingPackages: item?.pendingPackages,
|
|
})).sort((left, right) => `${left.inboundCode}:${left.destinationType}`.localeCompare(`${right.inboundCode}:${right.destinationType}`)),
|
|
})
|
|
}
|
|
|
|
const RECEIVING_BALANCE_BUCKETS = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages']
|
|
const RECEIVING_POSITION_BUCKETS = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
|
|
const RECEIVING_FIELDS = {
|
|
balances: ['id', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', ...RECEIVING_BALANCE_BUCKETS],
|
|
positions: ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseCode', 'palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'status', ...RECEIVING_POSITION_BUCKETS],
|
|
stockReceipts: ['id', 'customerId', 'planId', 'taskId', 'allocationId', 'lineId', 'orderId', 'inboundCode', 'skuCode', 'warehousePalletId', 'palletNo', 'warehouseLocationId', 'warehouseLocationCode', 'batchNo', 'receivedPackages', 'inventoryBalanceId', 'inventoryPositionId', 'inventoryOperationKey'],
|
|
inventoryOperations: ['id', 'customerId', 'idempotencyKey', 'operationType', 'requestHash', 'status'],
|
|
stockInLedger: ['id', 'customerId', 'balanceId', 'positionId', 'overseasWarehouseId', 'inboundOrderId', 'inboundCode', 'palletId', 'palletNo', 'batchNo', 'skuCode', 'businessType', 'businessOrderNo', 'sourceEventId', 'idempotencyKey', 'statusFrom', 'statusTo', 'beforePackages', 'deltaPackages', 'afterPackages'],
|
|
receivingOperations: ['id', 'sessionId', 'action', 'idempotencyKey', 'requestHash'],
|
|
httpGroups: ['id', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'skuCode', ...RECEIVING_BALANCE_BUCKETS],
|
|
httpLedger: ['id', 'customerId', 'overseasWarehouseId', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'palletNo', 'batchNo', 'skuCode', 'businessType', 'businessOrderNo', 'statusFrom', 'statusTo', 'beforePackages', 'deltaPackages', 'afterPackages'],
|
|
}
|
|
RECEIVING_FIELDS.palletComposition = RECEIVING_FIELDS.positions
|
|
const receivingNumberFields = new Set([...RECEIVING_BALANCE_BUCKETS, 'receivedPackages', 'beforePackages', 'deltaPackages', 'afterPackages'])
|
|
const receivingArrays = Object.keys(RECEIVING_FIELDS)
|
|
const sameEvidence = (left, right) => JSON.stringify(left) === JSON.stringify(right)
|
|
const receivingTotal = (rows, field) => rows.reduce((total, row) => total + row[field], 0)
|
|
const balanceGroupKey = row => JSON.stringify([row.customerId, row.overseasWarehouseId, row.destinationWarehouseId, row.destinationWarehouseCode, row.skuCode])
|
|
|
|
// Only safe business identities and bucket facts enter reports. Never save reader connection,
|
|
// tokens, operator identifiers, request/result JSON, binary files or arbitrary provider fields.
|
|
export function normalizeReceivingEvidence(value) {
|
|
if (!value || receivingArrays.some(name => !Array.isArray(value[name]))) return null
|
|
return Object.fromEntries(receivingArrays.map(name => [name, value[name].map(row =>
|
|
Object.fromEntries(RECEIVING_FIELDS[name].map(field => [field, receivingNumberFields.has(field)
|
|
? (row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) ? Number(row[field]) : null)
|
|
: row[field] == null ? null : id(row[field])]))).sort((a, b) => id(a.id).localeCompare(id(b.id)))]))
|
|
}
|
|
|
|
export function receivingEvidenceValid(snapshot, scope) {
|
|
if (!snapshot || receivingArrays.some(name => !Array.isArray(snapshot[name]) || snapshot[name].some(row => !longId(row.id))
|
|
|| new Set(snapshot[name].map(row => row.id)).size !== snapshot[name].length)) return false
|
|
const bucketsValid = (row, fields) => fields.every(field => Number.isSafeInteger(row[field]) && row[field] >= 0)
|
|
const owned = row => row.customerId === scope.customerId && row.overseasWarehouseId === scope.warehouseId
|
|
if (!snapshot.balances.every(row => owned(row) && bucketsValid(row, RECEIVING_BALANCE_BUCKETS))
|
|
|| !snapshot.httpGroups.every(row => owned(row) && bucketsValid(row, RECEIVING_BALANCE_BUCKETS))
|
|
|| !snapshot.httpLedger.every(owned)
|
|
|| !snapshot.positions.every(row => owned(row) && bucketsValid(row, RECEIVING_POSITION_BUCKETS)
|
|
&& snapshot.balances.some(balance => balance.id === row.balanceId)
|
|
&& ((longId(row.palletId) && nonBlank(row.palletNo)) || (row.palletId === '0' && row.palletNo === 'UNBOUND'))
|
|
&& longId(row.warehouseLocationId) && nonBlank(row.warehouseLocationCode))
|
|
|| !sameEvidence(snapshot.positions, snapshot.palletComposition)) return false
|
|
if (!snapshot.balances.every(balance => RECEIVING_POSITION_BUCKETS.every(field =>
|
|
receivingTotal(snapshot.positions.filter(row => row.balanceId === balance.id), field) === balance[field]))) return false
|
|
const groups = new Map()
|
|
for (const balance of snapshot.balances) {
|
|
const key = balanceGroupKey(balance)
|
|
const current = groups.get(key) || Object.fromEntries(RECEIVING_BALANCE_BUCKETS.map(field => [field, 0]))
|
|
for (const field of RECEIVING_BALANCE_BUCKETS) current[field] += balance[field]
|
|
groups.set(key, current)
|
|
}
|
|
for (const [key, totals] of groups) if (!RECEIVING_BALANCE_BUCKETS.some(field => totals[field] > 0)) groups.delete(key)
|
|
if (groups.size !== snapshot.httpGroups.length || new Set(snapshot.httpGroups.map(balanceGroupKey)).size !== snapshot.httpGroups.length
|
|
|| !snapshot.httpGroups.every(row => groups.has(balanceGroupKey(row)) && RECEIVING_BALANCE_BUCKETS.every(field => row[field] === groups.get(balanceGroupKey(row))[field]))) return false
|
|
if (!snapshot.stockReceipts.every(row => row.customerId === scope.customerId && scope.inboundCodes.includes(row.inboundCode)
|
|
&& longId(row.planId) && longId(row.taskId) && longId(row.allocationId) && longId(row.lineId) && longId(row.orderId)
|
|
&& longId(row.warehousePalletId) && nonBlank(row.palletNo)
|
|
&& nonBlank(row.skuCode) && Number.isSafeInteger(row.receivedPackages) && row.receivedPackages > 0)) return false
|
|
if (snapshot.inventoryOperations.length !== snapshot.stockReceipts.length || snapshot.stockInLedger.length !== snapshot.stockReceipts.length) return false
|
|
for (const receipt of snapshot.stockReceipts) {
|
|
const baseKey = `PDA_STOCK_IN:${receipt.taskId}:${receipt.allocationId}:${receipt.skuCode}`
|
|
const operations = snapshot.inventoryOperations.filter(row => row.idempotencyKey === baseKey)
|
|
if (receipt.inventoryOperationKey !== baseKey || operations.length !== 1) return false
|
|
const operation = operations[0]
|
|
const ledgers = snapshot.stockInLedger.filter(row => row.idempotencyKey === `${baseKey}:${operation.id}`)
|
|
if (operation.customerId !== scope.customerId || operation.operationType !== 'STOCK_IN' || operation.status !== 'COMPLETED'
|
|
|| !/^[a-f0-9]{64}$/.test(operation.requestHash || '') || ledgers.length !== 1) return false
|
|
const ledger = ledgers[0]
|
|
const balance = snapshot.balances.find(row => row.id === receipt.inventoryBalanceId)
|
|
const position = snapshot.positions.find(row => row.id === receipt.inventoryPositionId)
|
|
if (!balance || !position || position.balanceId !== balance.id || !owned(ledger) || ledger.businessType !== 'STOCK_IN'
|
|
|| ledger.balanceId !== balance.id || ledger.positionId !== position.id || ledger.inboundOrderId !== receipt.orderId
|
|
|| ledger.inboundCode !== receipt.inboundCode || ledger.skuCode !== receipt.skuCode || !nonBlank(ledger.sourceEventId)
|
|
|| ledger.palletId !== receipt.warehousePalletId || ledger.palletNo !== receipt.palletNo || ledger.batchNo !== receipt.batchNo
|
|
|| position.palletId !== receipt.warehousePalletId || position.palletNo !== receipt.palletNo
|
|
|| position.warehouseLocationId !== receipt.warehouseLocationId || position.warehouseLocationCode !== receipt.warehouseLocationCode
|
|
|| ledger.deltaPackages !== receipt.receivedPackages || ledger.afterPackages - ledger.beforePackages !== receipt.receivedPackages
|
|
|| ledger.statusTo !== 'AVAILABLE') return false
|
|
}
|
|
return snapshot.receivingOperations.every(row => row.action === 'COMPLETE' && longId(row.sessionId)
|
|
&& nonBlank(row.idempotencyKey) && /^[a-f0-9]{64}$/.test(row.requestHash || ''))
|
|
}
|
|
|
|
export function receivingCompletionProof(scope, stages) {
|
|
const names = ['BEFORE_COMPLETE', 'AFTER_COMPLETE', 'AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION']
|
|
const snapshotsValid = names.every(name => receivingEvidenceValid(stages[name], scope))
|
|
const checks = { completeScope: scope.inboundCodes.length > 0 && new Set(scope.inboundCodes).size === scope.inboundCodes.length
|
|
&& scope.inboundCodes.every(code => Number.isSafeInteger(scope.expectedPackagesByInbound[code]) && scope.expectedPackagesByInbound[code] > 0)
|
|
&& scope.sourceInboundIds.length === scope.inboundCodes.length && new Set(scope.sourceInboundIds).size === scope.sourceInboundIds.length,
|
|
fourCompleteSnapshots: snapshotsValid }
|
|
if (!snapshotsValid) return { passed: false, checks }
|
|
const before = stages.BEFORE_COMPLETE, after = stages.AFTER_COMPLETE
|
|
const expected = Object.values(scope.expectedPackagesByInbound).reduce((sum, count) => sum + count, 0)
|
|
checks.freshSourceHasNoPriorStock = ['stockReceipts', 'inventoryOperations', 'stockInLedger', 'receivingOperations'].every(name => before[name].length === 0)
|
|
checks.exactSourceReceiptAndLedgerQuantities = scope.inboundCodes.every(code => receivingTotal(after.stockReceipts.filter(row => row.inboundCode === code), 'receivedPackages') === scope.expectedPackagesByInbound[code]
|
|
&& receivingTotal(after.stockInLedger.filter(row => row.inboundCode === code), 'deltaPackages') === scope.expectedPackagesByInbound[code])
|
|
checks.completeCommandExactlyOnce = after.receivingOperations.length === 1 && after.receivingOperations[0].idempotencyKey === scope.completeKey
|
|
checks.firstCompleteAllBalanceBuckets = RECEIVING_BALANCE_BUCKETS.every(field => receivingTotal(after.balances, field) - receivingTotal(before.balances, field)
|
|
=== (field === 'availablePackages' ? expected : field === 'inTransitPackages' ? -scope.expectedTransitPackages : 0))
|
|
checks.firstCompleteAllPositionBuckets = RECEIVING_POSITION_BUCKETS.every(field => receivingTotal(after.positions, field) - receivingTotal(before.positions, field)
|
|
=== (field === 'availablePackages' ? expected : 0))
|
|
const touchedBalances = new Set(after.stockReceipts.map(row => row.inventoryBalanceId))
|
|
const touchedPositions = new Set(after.stockReceipts.map(row => row.inventoryPositionId))
|
|
checks.untouchedBalancesAndPositionsUnchanged = sameEvidence(before.balances.filter(row => !touchedBalances.has(row.id)), after.balances.filter(row => !touchedBalances.has(row.id)))
|
|
&& sameEvidence(before.positions.filter(row => !touchedPositions.has(row.id)), after.positions.filter(row => !touchedPositions.has(row.id)))
|
|
const oldLedgerIds = new Set(before.httpLedger.map(row => row.id))
|
|
const delta = after.httpLedger.filter(row => !oldLedgerIds.has(row.id))
|
|
checks.publicImmutableLedgerPreservedAndNewStockExact = sameEvidence(before.httpLedger, after.httpLedger.filter(row => oldLedgerIds.has(row.id)))
|
|
&& delta.filter(row => row.businessType === 'STOCK_IN').length === after.stockInLedger.length
|
|
&& delta.filter(row => row.businessType === 'STOCK_IN').every(row => after.stockInLedger.some(source => source.id === row.id && source.deltaPackages === row.deltaPackages))
|
|
&& delta.every(row => scope.inboundCodes.includes(row.inboundCode) && ['STOCK_IN', 'STOCK_IN_TRANSIT_RELEASE'].includes(row.businessType))
|
|
checks.sameKeyReplayAllRawBucketsPositionsPalletsAndImmutableKeysUnchanged = sameEvidence(after, stages.AFTER_SAME_KEY_REPLAY)
|
|
checks.freshKeyRejectedAllRawBucketsPositionsPalletsAndImmutableKeysUnchanged = sameEvidence(after, stages.AFTER_FRESH_KEY_REJECTION)
|
|
return { passed: Object.values(checks).every(Boolean), checks, expectedPackages: expected, expectedTransitPackages: scope.expectedTransitPackages }
|
|
}
|
|
|
|
async function receivingEvidencePages(context, scope, resource, name) {
|
|
const collected = []
|
|
for (let page = 1; page <= 100; page++) {
|
|
const response = await context.request('GET', `/api/platform/v1/last-leg/inventory/${resource}?customerId=${encodeURIComponent(scope.customerId)}&overseasWarehouseId=${encodeURIComponent(scope.warehouseId)}&page=${page}&size=200`)
|
|
const data = response.body?.data
|
|
const valid = success(response) && Array.isArray(data?.records) && Number.isSafeInteger(Number(data?.total)) && Number(data.total) >= 0
|
|
context.addStep(MODULE, `${name}完整客户仓${resource}第${page}页`, response, valid, trace(response), OPTIONS)
|
|
if (!valid) return null
|
|
collected.push(...data.records)
|
|
if (collected.length >= Number(data.total)) {
|
|
const complete = collected.length === Number(data.total) && new Set(collected.map(row => id(row.id))).size === collected.length
|
|
context.assert(MODULE, `${name}${resource}分页无截断重复`, response, complete, `rows=${collected.length}, total=${data.total}`, OPTIONS)
|
|
return complete ? collected : null
|
|
}
|
|
if (!data.records.length) break
|
|
}
|
|
blockerGap(context, `${name}库存分页`, '完整客户仓分页未读完,不能冻结截断库存', 'receiving-replay-evidence')
|
|
return null
|
|
}
|
|
|
|
export async function captureReceivingEvidence(context, scope, stage, target) {
|
|
const slot = target.stages[stage] = { captured: false, evidence: null }
|
|
const reader = context.context?.phase4ReceivingEvidenceReader
|
|
if (typeof reader !== 'function') {
|
|
blockerGap(context, `${stage}原始库存与不可变完整键取证`, '未注入phase4ReceivingEvidenceReader;公共ledger不返回原始balance/position/幂等键且无immutable receipt接口,不能以终态补历史快照', 'receiving-replay-evidence')
|
|
return null
|
|
}
|
|
try {
|
|
const httpGroups = await receivingEvidencePages(context, scope, 'balances', stage)
|
|
const httpLedger = await receivingEvidencePages(context, scope, 'ledger', stage)
|
|
if (!httpGroups || !httpLedger) return null
|
|
const raw = await reader({ ...scope, stage })
|
|
const evidence = normalizeReceivingEvidence({ ...raw, httpGroups, httpLedger })
|
|
slot.evidence = evidence
|
|
slot.captured = receivingEvidenceValid(evidence, scope)
|
|
context.assert(MODULE, `${stage}完整raw库存位置托盘组成及收货完整键核平`, null, slot.captured,
|
|
`bookingId=${scope.bookingId}, balances=${evidence?.balances.length ?? 'missing'}, positions=${evidence?.positions.length ?? 'missing'}, receipts=${evidence?.stockReceipts.length ?? 'missing'}`, OPTIONS)
|
|
return evidence
|
|
} catch {
|
|
slot.error = 'Read-only receiving evidence reader failed; sensitive connection/provider details omitted'
|
|
blockerGap(context, `${stage}只读取证`, slot.error, 'receiving-replay-evidence')
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function receivingEventRecoveryInventoryUnchanged(scope, before, after) {
|
|
return receivingEvidenceValid(before, scope) && receivingEvidenceValid(after, scope)
|
|
&& sameEvidence(before, after)
|
|
}
|
|
|
|
export function expandOverseasCartonCandidates(inbounds, producerCartonMarks = []) {
|
|
const marks = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
|
|
.map(id).filter(nonBlank)
|
|
return inbounds.filter((item) => item?.destinationType === 'OVERSEAS').flatMap((inbound) => {
|
|
const patterns = marks.map((cartonMark) => ({ cartonMark, match: /^(.*)#(\d+)-(\d+)$/.exec(cartonMark) }))
|
|
.filter(({ match }) => match && match[1] === inbound.inboundCode)
|
|
const totals = [...new Set(patterns.map(({ match }) => Number(match[2]))
|
|
.filter((total) => Number.isSafeInteger(total) && total > 0 && total <= 2000))]
|
|
if (totals.length !== 1) return []
|
|
return Array.from({ length: totals[0] }, (_, index) => `${inbound.inboundCode}#${totals[0]}-${index + 1}`)
|
|
})
|
|
}
|
|
|
|
function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) {
|
|
context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true })
|
|
context.assert(MODULE, `${name}覆盖前置`, response, false, reason, OPTIONS)
|
|
}
|
|
|
|
async function pda(context, token, method, name, path, body = undefined) {
|
|
const response = await context.request(method, path, body, token, {})
|
|
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
|
|
return { response, data: success(response) ? response.body?.data : null }
|
|
}
|
|
|
|
async function expected(context, token, method, name, path, body, predicate) {
|
|
const response = await context.request(method, path, body, token, {})
|
|
context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS)
|
|
return response
|
|
}
|
|
|
|
async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
|
|
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
|
const candidates = records(response?.body?.data)
|
|
.filter((item) => id(item?.containerNo) === id(containerNo))
|
|
const exactCustomer = expectedCustomerId == null ? candidates
|
|
: candidates.filter((item) => id(item?.customerId) === id(expectedCustomerId))
|
|
const order = exactCustomer[0] || candidates[0]
|
|
const mapped = success(response) && longId(order?.id) && nonBlank(id(order?.customerId))
|
|
context.assert(MODULE, '完成柜回查平台尾程订单并冻结跨区身份', response, mapped,
|
|
`containerNo=${containerNo}, orderId=${id(order?.id) || 'missing'}, customerId=${id(order?.customerId) || 'missing'}; ${trace(response)}`, OPTIONS)
|
|
if (mapped) {
|
|
context.createdResources.phase4OverseasPda.orderId = id(order.id)
|
|
context.createdResources.phase4OverseasPda.customerId = id(order.customerId)
|
|
const detailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}?customerId=${encodeURIComponent(id(order.customerId))}`
|
|
const detail = await context.request('GET', detailPath)
|
|
context.assert(MODULE, '完成柜回查尾程订单货物明细供后续PDA作业', detail,
|
|
success(detail) && id(detail.body?.data?.order?.id) === id(order.id)
|
|
&& Array.isArray(detail.body?.data?.cargo) && detail.body.data.cargo.length > 0,
|
|
`orderId=${id(order.id)}, cargo=${detail.body?.data?.cargo?.length ?? 'missing'}; ${trace(detail)}`, OPTIONS)
|
|
return success(detail) ? detail.body?.data : null
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function loginMerchantForNegativeProbe(context) {
|
|
const fixture = context.context?.merchantQualificationFixture
|
|
const username = fixture?.username || context.options?.merchantUsername
|
|
const password = fixture?.password || context.options?.merchantPassword
|
|
if (!username || !password) return null
|
|
const response = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
|
const data = success(response) ? response.body?.data : null
|
|
context.addStep(MODULE, '本轮客户登录取得访问令牌用于PDA越权反证', response,
|
|
success(response) && nonBlank(data?.accessToken),
|
|
`tokenPresent=${nonBlank(data?.accessToken)}; ${trace(response)}`, OPTIONS)
|
|
return nonBlank(data?.accessToken) ? data.accessToken : null
|
|
}
|
|
|
|
export async function resolvePickerAccess(context) {
|
|
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
|
|
const roles = context.apiSuccess(MODULE, '查询海外PDA仓库操作员角色', rolesResponse, '', OPTIONS)
|
|
const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item?.status === 'ACTIVE')
|
|
const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions')
|
|
const permissions = context.apiSuccess(MODULE, '查询海外PDA权限资源', permissionsResponse, '', OPTIONS)
|
|
const operate = (Array.isArray(permissions) ? permissions : []).find((item) => item?.permissionCode === 'lastleg:pda:operate')
|
|
const assigned = Boolean(role && operate && (role.permissionIds || []).map(id).includes(id(operate.id)))
|
|
context.assert(MODULE, 'OVERSEAS_WAREHOUSE_PDA_OPERATOR精确具备海外PDA权限', rolesResponse, assigned,
|
|
`roleId=${id(role?.id) || 'missing'}, permissionId=${id(operate?.id) || 'missing'}`, OPTIONS)
|
|
if (!assigned) {
|
|
blockerGap(context, '海外PDA仓库操作员权限', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM')
|
|
return null
|
|
}
|
|
return { role, operate }
|
|
}
|
|
|
|
export async function createPicker(context, index, overseasWarehouseId, access) {
|
|
const role = access.role
|
|
const suffix = markerSuffix(context)
|
|
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
|
|
const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9`
|
|
const phone = await context.findAvailablePhone(MODULE)
|
|
const payload = { username, displayName: `海外PDA验收操作员${index}-${suffix}`, phone,
|
|
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
|
|
overseasWarehouseId, password, status: 'ACTIVE' }
|
|
const createdResponse = await context.request('POST', '/api/platform/v1/iam/users', payload)
|
|
const created = context.apiSuccess(MODULE, `创建海外PDA仓库操作员${index}`, createdResponse, '', OPTIONS)
|
|
const membershipId = id(created?.membershipId)
|
|
context.assert(MODULE, `海外PDA操作员${index}冻结平台范围与角色`, createdResponse,
|
|
longId(membershipId) && id(created?.tenantId) === '0'
|
|
&& id(created?.overseasWarehouseId) === id(overseasWarehouseId)
|
|
&& (created?.roleIds || []).map(id).includes(id(role.id)),
|
|
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, overseasWarehouseId=${id(created?.overseasWarehouseId) || 'missing'}`, OPTIONS)
|
|
if (!longId(membershipId)) return null
|
|
const login = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
|
const token = success(login) ? login.body?.data?.accessToken : null
|
|
context.addStep(MODULE, `海外PDA操作员${index}登录取得统一访问令牌`, login, nonBlank(token),
|
|
`tokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS)
|
|
if (!nonBlank(token)) return { membershipId, username, token: null }
|
|
|
|
let projectionResponse = null
|
|
let projectionReady = false
|
|
let projectionAttempt = 0
|
|
for (; projectionAttempt < 30; projectionAttempt += 1) {
|
|
projectionResponse = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
|
projectionReady = success(projectionResponse) || notFound(projectionResponse)
|
|
if (projectionReady || !forbidden(projectionResponse)) break
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
context.addStep(MODULE, `海外PDA操作员${index}安全投影已收敛`, projectionResponse, projectionReady,
|
|
`attempts=${projectionAttempt + 1}; ${trace(projectionResponse)}`, OPTIONS)
|
|
return { membershipId, username, token: projectionReady ? token : null,
|
|
refreshToken: login.body?.data?.refreshToken, password,
|
|
userUpdate: { displayName: payload.displayName, phone, email: payload.email, tenantId: '0',
|
|
roleIds: payload.roleIds, overseasWarehouseId, domesticWarehouseId: null } }
|
|
}
|
|
|
|
export function pickerSessionHandoff(picker) {
|
|
if (!picker) return null
|
|
const { membershipId, username, token, refreshToken, password, userUpdate } = picker
|
|
return { membershipId, username, token, refreshToken, password,
|
|
userUpdate: userUpdate ? { ...userUpdate, roleIds: [...userUpdate.roleIds] } : undefined }
|
|
}
|
|
|
|
export async function disablePicker(context, picker, index) {
|
|
if (!longId(picker?.membershipId)) return
|
|
const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' })
|
|
const disabled = success(response) && response.body?.data?.status === 'DISABLED'
|
|
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
|
|
}
|
|
|
|
function rememberGeneratedPlans(context, plans) {
|
|
if (!Array.isArray(plans)) return
|
|
for (const plan of plans) {
|
|
const inboundCode = id(plan?.lines?.[0]?.inboundCode)
|
|
if (!nonBlank(inboundCode)) continue
|
|
const generated = Array.isArray(plan?.pallets) ? plan.pallets : []
|
|
context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inboundCode] = generated.map((pallet) => ({
|
|
warehouseId: id(plan?.overseasWarehouseId),
|
|
locationId: id(pallet?.warehouseLocationId),
|
|
palletId: id(pallet?.warehousePalletId),
|
|
palletCode: id(pallet?.palletNo),
|
|
unitPrice: Number(pallet?.unitPrice || 0),
|
|
retained: true,
|
|
}))
|
|
}
|
|
}
|
|
|
|
async function resolveGeneratedPallet(context, inbound) {
|
|
const candidates = context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inbound.inboundCode] || []
|
|
const pallet = candidates[0]
|
|
if (!pallet || !longId(pallet.palletId) || !nonBlank(pallet.palletCode)) return null
|
|
const warehouseId = id(inbound?.overseasWarehouseId || pallet.warehouseId)
|
|
const listed = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`)
|
|
const stored = records(listed?.body?.data).find((row) => id(row?.id) === pallet.palletId && row?.palletCode === pallet.palletCode)
|
|
const locations = stored && longId(stored?.locationId)
|
|
? await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`)
|
|
: null
|
|
const location = records(locations?.body?.data).find((row) => id(row?.id) === id(stored?.locationId))
|
|
const resolved = success(listed) && success(locations) && stored && longId(stored?.locationId) && nonBlank(location?.locationCode)
|
|
context.addStep(MODULE, `复用入仓码${inbound.inboundCode}自动计划托盘`, listed, resolved,
|
|
`palletCode=${pallet.palletCode}, palletId=${pallet.palletId}, locationId=${id(stored?.locationId) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(listed)}`, OPTIONS)
|
|
if (!resolved) return null
|
|
return { ...pallet, warehouseId, locationId: id(stored.locationId), locationCode: location.locationCode,
|
|
unitPrice: Number(stored.unitPrice || pallet.unitPrice || 0) }
|
|
}
|
|
|
|
async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) {
|
|
const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存',
|
|
'/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode })
|
|
const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : []
|
|
context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response,
|
|
success(inventory.response) && inventory.data?.queryCode === inboundCode
|
|
&& inventoryItems.length > 0
|
|
&& inventoryItems.every((item) => longId(id(item?.palletId))
|
|
&& longId(id(item?.locationId)) && nonBlank(item?.palletCode)
|
|
&& nonBlank(item?.locationCode) && Number(item?.availablePackages) > 0),
|
|
`items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS)
|
|
context.assert(MODULE, `${destinationType}实收形成服务端SKU库存账`, inventory.response,
|
|
success(inventory.response) && inventoryItems.length > 0
|
|
&& inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0),
|
|
`skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS)
|
|
|
|
const occupied = [...inventoryItems].sort((left, right) =>
|
|
Number(right?.availablePackages || 0) - Number(left?.availablePackages || 0))[0]
|
|
const actualPalletCode = occupied?.palletCode || pallet.palletCode
|
|
const actualPalletId = id(occupied?.palletId || pallet.palletId)
|
|
const actualLocationCode = occupied?.locationCode || pallet.locationCode
|
|
const actualLocationId = id(occupied?.locationId || pallet.locationId)
|
|
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
|
|
'/api/pda/v1/last-leg/locations/resolve', { code: actualLocationCode })
|
|
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
|
|
success(location.response) && id(location.data?.locationId) === actualLocationId
|
|
&& location.data?.locationCode === actualLocationCode
|
|
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
|
|
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
|
|
|
|
const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容',
|
|
'/api/pda/v1/last-leg/pallets/resolve', { code: actualPalletCode })
|
|
context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response,
|
|
success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === actualPalletId
|
|
&& id(resolvedPallet.data?.locationId) === actualLocationId
|
|
&& Array.isArray(resolvedPallet.data?.inventory) && resolvedPallet.data.inventory.length > 0
|
|
&& Number(resolvedPallet.data?.occupiedPackages) > 0,
|
|
`palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS)
|
|
|
|
const history = await pda(context, token, 'GET', '查询当前仓管PDA作业历史',
|
|
'/api/pda/v1/last-leg/operations?page=1&size=50')
|
|
const historyRows = records(history.data)
|
|
context.assert(MODULE, '作业历史包含本轮柜号收货动作且分页稳定', history.response,
|
|
success(history.response) && historyRows.length > 0
|
|
&& historyRows.some((item) => String(item?.operationType || '').startsWith('RECEIVING_'))
|
|
&& Number(history.data?.page) === 1 && Number(history.data?.size) === 50,
|
|
`records=${historyRows.length}; ${trace(history.response)}`, OPTIONS)
|
|
return success(inventory.response) && success(location.response) && success(resolvedPallet.response)
|
|
? { inboundCode, pallet: resolvedPallet.data, location: location.data,
|
|
inventory: inventoryItems.filter((item) => id(item?.palletId) === actualPalletId) }
|
|
: null
|
|
}
|
|
|
|
export async function uploadPhotos(context, token) {
|
|
const malicious = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
|
name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('<script>alert(1)</script>')),
|
|
}, token)
|
|
context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious,
|
|
malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS)
|
|
const photos = []
|
|
for (const role of PHOTO_ROLES) {
|
|
const response = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
|
name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG,
|
|
}, token)
|
|
const fileId = id(response?.body?.data?.fileId)
|
|
context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId),
|
|
`role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS)
|
|
if (!longId(fileId)) return null
|
|
photos.push({ role, fileId })
|
|
}
|
|
return photos
|
|
}
|
|
|
|
export async function awaitProducerConsumption(context, producers) {
|
|
const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
|
if (!expected.length) return true
|
|
const consumed = new Set()
|
|
const lastResponses = new Map()
|
|
for (let attempt = 1; attempt <= 90; attempt += 1) {
|
|
const missing = expected.filter((containerNo) => !consumed.has(containerNo))
|
|
const results = await Promise.all(missing.map(async (containerNo) => {
|
|
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
|
return { containerNo, response }
|
|
}))
|
|
for (const { containerNo, response } of results) {
|
|
lastResponses.set(containerNo, response)
|
|
const rows = records(response?.body?.data)
|
|
if (success(response) && rows.some((item) => id(item?.containerNo) === containerNo)) {
|
|
consumed.add(containerNo)
|
|
}
|
|
}
|
|
if (expected.every((containerNo) => consumed.has(containerNo))) break
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
}
|
|
for (const containerNo of expected) {
|
|
const response = lastResponses.get(containerNo) || null
|
|
context.addStep(MODULE, `跨区广播消费观察 ${containerNo}`, response,
|
|
true,
|
|
`containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OBSERVATION_OPTIONS)
|
|
}
|
|
return expected.every((containerNo) => consumed.has(containerNo))
|
|
}
|
|
|
|
export async function convertReceivingContainers(context, producers) {
|
|
for (const producer of producers) {
|
|
const containerNo = id(producer?.containerNo)
|
|
const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {})
|
|
const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records)
|
|
? summaryResponse.body.data.records : []
|
|
const summary = summaries.find((item) => id(item?.containerNo) === containerNo)
|
|
const totalOrders = Number(summary?.totalOrderCount)
|
|
const inboundCodes = Number(summary?.inboundCodeCount)
|
|
const existingPlans = Number(summary?.planCount)
|
|
const pendingPlans = Number(summary?.pendingPlanCount)
|
|
const convertible = nonBlank(summary?.sourceContainerBookingId)
|
|
&& totalOrders > 0 && inboundCodes === totalOrders
|
|
&& Number(summary?.missingInboundCodeCount) === 0
|
|
&& ((existingPlans === 0 && pendingPlans === totalOrders)
|
|
|| (summary?.allPlansReady === true && existingPlans === totalOrders && pendingPlans === 0))
|
|
context.addStep(MODULE, `读取待分配柜级摘要 ${containerNo}`, summaryResponse,
|
|
convertible,
|
|
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
|
|
if (!convertible) return false
|
|
const bookingId = id(summary.sourceContainerBookingId)
|
|
const arrivalQueryResponse = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] }, context.token, {})
|
|
const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data)
|
|
? arrivalQueryResponse.body.data : []
|
|
let arrivalTime = arrivalTimes.find((item) => id(item?.sourceContainerBookingId) === bookingId)
|
|
context.addStep(MODULE, `读取整柜预计到仓时间 ${containerNo}`, arrivalQueryResponse,
|
|
Boolean(arrivalTime) && Number.isInteger(Number(arrivalTime?.version)),
|
|
`sourceContainerBookingId=${bookingId}, estimatedWarehouseArrivalAt=${arrivalTime?.estimatedWarehouseArrivalAt || 'missing'}, version=${arrivalTime?.version ?? 'missing'}; ${trace(arrivalQueryResponse)}`,
|
|
OPTIONS)
|
|
if (!arrivalTime) return false
|
|
if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) {
|
|
const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19)
|
|
const arrivalUpdateResponse = await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version),
|
|
remark: `${context.marker}-overseas-inbound-plan-prerequisite` }, context.token, { 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) })
|
|
arrivalTime = success(arrivalUpdateResponse) ? arrivalUpdateResponse.body?.data : null
|
|
context.addStep(MODULE, `维护整柜预计到仓时间 ${containerNo}`, arrivalUpdateResponse,
|
|
id(arrivalTime?.sourceContainerBookingId) === bookingId
|
|
&& nonBlank(arrivalTime?.estimatedWarehouseArrivalAt),
|
|
`sourceContainerBookingId=${bookingId}, estimatedWarehouseArrivalAt=${arrivalTime?.estimatedWarehouseArrivalAt || 'missing'}, version=${arrivalTime?.version ?? 'missing'}; ${trace(arrivalUpdateResponse)}`,
|
|
OPTIONS)
|
|
if (!arrivalTime || !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) return false
|
|
}
|
|
const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}`
|
|
const previewResponse = await context.request('GET', previewPath, undefined, context.token, {})
|
|
const preview = success(previewResponse) ? previewResponse.body?.data : null
|
|
context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse,
|
|
preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders
|
|
&& Number(preview?.pendingOrderCount) === pendingPlans,
|
|
`orders=${preview?.totalOrderCount ?? 'missing'}, pendingOrders=${preview?.pendingOrderCount ?? 'missing'}, pendingPallets=${preview?.pendingPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
|
|
OPTIONS)
|
|
if (preview?.ready !== true) return false
|
|
if (pendingPlans > 0) {
|
|
const body = { sourceContainerBookingId: bookingId,
|
|
snapshotVersion: Number(preview.snapshotVersion), snapshotHash: String(preview.snapshotHash) }
|
|
const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
|
|
const convertedResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key })
|
|
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
|
|
? convertedResponse.body.data : []
|
|
const convertedInboundCodes = new Set(plans
|
|
.flatMap((item) => Array.isArray(item?.lines) ? item.lines : [])
|
|
.map((line) => id(line?.inboundCode)).filter(nonBlank))
|
|
context.addStep(MODULE, `整柜转换为逐入仓码独立计划及实体托盘 ${containerNo}`, convertedResponse,
|
|
plans.length === inboundCodes
|
|
&& plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0
|
|
&& Array.isArray(item?.pallets) && item.pallets.length > 0
|
|
&& item.pallets.every((pallet) => nonBlank(pallet?.palletNo) && longId(id(pallet?.warehousePalletId))))
|
|
&& convertedInboundCodes.size === inboundCodes,
|
|
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
|
|
if (plans.length !== inboundCodes) return false
|
|
const actualPalletCodes = plans.flatMap((item) => Array.isArray(item?.pallets) ? item.pallets : [])
|
|
.map((pallet) => id(pallet?.palletNo)).filter(nonBlank).sort()
|
|
const expectedPalletCodes = (producer?.palletLabelManifest?.groups || [])
|
|
.flatMap((group) => group?.labels || []).map((label) => id(label?.palletCode)).filter(nonBlank).sort()
|
|
if (producer?.destinationType === 'FBA' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') {
|
|
context.assert(MODULE, `头程 FBA 散货托盘码跨区原样用于实体托盘 ${containerNo}`, convertedResponse,
|
|
expectedPalletCodes.length > 0
|
|
&& JSON.stringify(actualPalletCodes) === JSON.stringify(expectedPalletCodes),
|
|
`source=${expectedPalletCodes.join(',') || 'missing'}, overseas=${actualPalletCodes.join(',') || 'missing'}`,
|
|
OPTIONS)
|
|
}
|
|
if (producer?.destinationType === 'OVERSEAS' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') {
|
|
context.assert(MODULE, `标准海外仓订单由海外仓生成本地实体托盘码 ${containerNo}`, convertedResponse,
|
|
expectedPalletCodes.length === 0 && actualPalletCodes.length > 0
|
|
&& actualPalletCodes.every((code) => code.startsWith('PLT-')),
|
|
`sourceCount=${expectedPalletCodes.length}, overseas=${actualPalletCodes.join(',') || 'missing'}`,
|
|
OPTIONS)
|
|
}
|
|
rememberGeneratedPlans(context, plans)
|
|
const replayResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key })
|
|
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
|
|
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
|
|
trace(replayResponse), OPTIONS)
|
|
if (!success(replayResponse)) return false
|
|
} else {
|
|
const retainedPlans = context.createdResources?.phase4TailOrderControls?.generatedPlans
|
|
rememberGeneratedPlans(context, retainedPlans)
|
|
context.addStep(MODULE, `复用尾程控制已生成的整柜计划与实体托盘 ${containerNo}`, previewResponse,
|
|
summary?.allPlansReady === true && existingPlans === totalOrders
|
|
&& Number(preview?.existingPlannedPalletCount) > 0,
|
|
`plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
|
|
OPTIONS)
|
|
}
|
|
const progressResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, undefined, context.token, {})
|
|
const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null
|
|
context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse,
|
|
progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders
|
|
&& Number(progress?.pendingPlanCount) === 0,
|
|
`allPlansReady=${progress?.allPlansReady === true}, plans=${progress?.planCount ?? 'missing'}, pending=${progress?.pendingPlanCount ?? 'missing'}, plannedArrivalAt=${progress?.plannedArrivalAt || 'missing'}; ${trace(progressResponse)}`, OPTIONS)
|
|
if (progress?.allPlansReady !== true) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function awaitPickupReadyCandidates(context, token, producers, allProducers) {
|
|
const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
|
const expectedSet = new Set(expected)
|
|
const awaiting = [...new Set(allProducers
|
|
.filter((item) => item?.shippingReady === false && !expectedSet.has(id(item?.containerNo)))
|
|
.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
|
const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank))
|
|
if (!expected.length) return false
|
|
let response = null
|
|
let candidatesByContainer = new Map()
|
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
response = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, token, {})
|
|
const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : []
|
|
candidatesByContainer = new Map(candidateRows
|
|
.map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo)))
|
|
if (expected.every((containerNo) => candidatesByContainer.get(containerNo)?.operable === true)
|
|
&& awaiting.every((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true)) break
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
}
|
|
const seenExpected = expected.filter((containerNo) => candidatesByContainer.has(containerNo))
|
|
const missingPickupReady = expected.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true)
|
|
const unexpectedAwaiting = awaiting.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable === true)
|
|
const unexpectedHistorical = [...candidatesByContainer.keys()].filter((containerNo) => !known.has(containerNo))
|
|
const targetCorrelated = producers.every((item) => nonBlank(item?.resourceKey)
|
|
&& item?.shippingReady !== false && candidatesByContainer.get(id(item?.containerNo))?.operable === true)
|
|
const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated
|
|
context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready,
|
|
`targetCorrelated=${targetCorrelated}, expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS)
|
|
return ready
|
|
}
|
|
|
|
export async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = [], dropshipProducer = null) {
|
|
const strictDropship = dropshipProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
|
|
if (strictDropship) {
|
|
const sealed = dropshipProducer.sealedCartonMarks
|
|
const inbound = inbounds.find((item) => item.inboundCode === dropshipProducer.inboundCode)
|
|
const valid = Array.isArray(sealed) && sealed.length > 0 && sealed.every(nonBlank)
|
|
&& new Set(sealed).size === sealed.length && ['FBA', 'OVERSEAS'].includes(inbound?.destinationType)
|
|
&& inbound.destinationType === dropshipProducer.destinationType
|
|
&& sealed.length === Number(inbound.totalPackages)
|
|
context.assert(MODULE, '国内清点封托原箱码完整传递到当前海外收货柜', null, valid,
|
|
`sealed=${sealed?.length ?? 'missing'}, expected=${inbound?.totalPackages ?? 'missing'}`, OPTIONS)
|
|
if (!valid) return null
|
|
producerCartonMarks = sealed
|
|
}
|
|
const manifestCartons = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
|
|
.map(id).filter(nonBlank)
|
|
const fbaCartons = (strictDropship ? [] : inbounds.filter((item) => item.destinationType === 'FBA'))
|
|
.map((item, index) => `${item.inboundCode}#PDA-CONTRACT-${index + 1}`)
|
|
const overseasCartons = strictDropship ? manifestCartons : expandOverseasCartonCandidates(inbounds, manifestCartons)
|
|
const cartonCandidates = [...new Set([...fbaCartons, ...overseasCartons])]
|
|
if (!cartonCandidates.length) {
|
|
blockerGap(context, '统一箱码解析链', 'FBA缺少当前柜入仓码或OVERSEAS缺少真实manifest箱码')
|
|
return null
|
|
}
|
|
const cartonFixtures = []
|
|
for (const [index, cartonMark] of cartonCandidates.entries()) {
|
|
const response = await context.request('POST', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, token, {})
|
|
if (!strictDropship && overseasCartons.includes(cartonMark)
|
|
&& a0404Message(response, /^Carton mark was not found$/)) {
|
|
context.addStep(MODULE, `排除未实装箱码候选${index + 1}`, response, true,
|
|
`cartonMark=${cartonMark}; ${trace(response)}`, OPTIONS)
|
|
continue
|
|
}
|
|
const resolved = response.body?.data
|
|
const delimiter = cartonMark.indexOf('#')
|
|
const scannedInboundCode = delimiter > 0 ? cartonMark.slice(0, delimiter).trim() : ''
|
|
const resolvedInboundCodes = Array.isArray(resolved?.inboundCodes)
|
|
? resolved.inboundCodes.map(id) : [id(resolved?.inboundCode)].filter(nonBlank)
|
|
const matches = inbounds.filter((item) => item.inboundCode === scannedInboundCode)
|
|
const destinations = new Set(matches.map((item) => item?.destinationType).filter(nonBlank))
|
|
const unique = success(response) && nonBlank(resolved?.inboundCode) && !Array.isArray(resolved?.inboundCode)
|
|
&& matches.length === 1 && destinations.size === 1
|
|
&& ['FBA', 'OVERSEAS'].includes(resolved?.destinationType)
|
|
&& destinations.has(resolved?.destinationType)
|
|
&& resolvedInboundCodes.includes(scannedInboundCode)
|
|
&& (!strictDropship || (resolved?.cartonMark === cartonMark
|
|
&& resolved?.inboundCode === dropshipProducer.inboundCode && resolved?.destinationType === dropshipProducer.destinationType))
|
|
context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response,
|
|
unique, `cartonMark=${cartonMark}, inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}; ${trace(response)}`, OPTIONS)
|
|
if (!unique) {
|
|
blockerGap(context, `箱码${cartonMark}唯一归属`,
|
|
`FBA箱码与OVERSEAS真实manifest箱码必须HTTP 200/code 200并返回当前柜唯一入仓码;inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}`,
|
|
'fixture-mismatch', response)
|
|
return null
|
|
}
|
|
cartonFixtures.push({ cartonMark, inbound: matches[0], resolved })
|
|
}
|
|
return cartonFixtures
|
|
}
|
|
|
|
export async function probeCartonResolveNegatives(context, token, current, runCrossContainerProbe = true) {
|
|
const fba = current.inbounds.find((item) => item.destinationType === 'FBA')
|
|
await expected(context, token, 'POST', '无#箱码被服务端拒绝',
|
|
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
|
|
{ cartonMark: fba?.inboundCode || `NO-HASH-${markerSuffix(context)}` }, cartonResolveRejected)
|
|
await expected(context, token, 'POST', '空#前缀箱码被服务端拒绝',
|
|
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
|
|
{ cartonMark: `#${markerSuffix(context)}` }, cartonResolveRejected)
|
|
await expected(context, token, 'POST', '未命中入仓码前缀被服务端拒绝',
|
|
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
|
|
{ cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected)
|
|
|
|
if (runCrossContainerProbe) {
|
|
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
|
|
const crossContainerProbe = crossContainerCartonProbe(allProducers,current)
|
|
if (crossContainerProbe) {
|
|
context.addStep(MODULE,'跨柜解析反证来源为另一真实实装柜及入仓码',null,true,
|
|
JSON.stringify(crossContainerProbe),OPTIONS)
|
|
await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝',
|
|
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
|
|
{ cartonMark: crossContainerProbe.cartonMark }, cartonResolveRejected)
|
|
} else {
|
|
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
|
|
}
|
|
}
|
|
|
|
const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS')
|
|
if (overseas) {
|
|
const total = Math.max(1, Number(overseas.totalPackages || 1))
|
|
await expected(context, token, 'POST', 'OVERSEAS缺少真实manifest行时不得按标准箱码推导',
|
|
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
|
|
{ cartonMark: `${overseas.inboundCode}#${total + 1}-1` },
|
|
(response) => a0404Message(response, /^Carton mark was not found$/))
|
|
}
|
|
}
|
|
|
|
async function receiveFba(context, token, inbounds, pallet, cartonFixtures, index) {
|
|
const inboundCodes = new Set(inbounds.map((item) => item.inboundCode))
|
|
const resolvedCartons = cartonFixtures.filter((item) => inboundCodes.has(item.inbound.inboundCode)
|
|
&& item.resolved.destinationType === 'FBA')
|
|
if (resolvedCartons.length !== inbounds.length) {
|
|
blockerGap(context, `FBA组${[...inboundCodes].join('|')}箱码解析`, '没有为FBA组内每个入仓码完成唯一解析')
|
|
return false
|
|
}
|
|
const packageCount = inbounds.reduce((total, inbound) => total + Number(inbound.totalPackages || 0), 0)
|
|
if (packageCount <= 0) return true
|
|
const body = { inboundCode: inbounds[0].inboundCode, palletCode: pallet.palletCode,
|
|
packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) }
|
|
const first = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}按累计箱数实收`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
|
if (!success(first.response)) return false
|
|
const replay = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}同键同载荷重放`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
|
context.assert(MODULE, `FBA组${[...inboundCodes].join('|')}幂等结果一致`, replay.response,
|
|
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
|
await expected(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}同键异载荷冲突`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict)
|
|
const already = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}新键重复累计为零增量`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
|
|
{ ...body, idempotencyKey: key(context, `fba-already-${index}`) })
|
|
context.assert(MODULE, `FBA组${[...inboundCodes].join('|')}重复累计不产生超收`, already.response,
|
|
success(already.response) && already.data?.result === 'ALREADY_RECEIVED'
|
|
&& Number(already.data?.acceptedPackages) === 0, trace(already.response), OPTIONS)
|
|
return true
|
|
}
|
|
|
|
async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures) {
|
|
const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0)
|
|
const overseasFixtures = cartonFixtures.filter((item) => item.resolved.destinationType === 'OVERSEAS'
|
|
&& inbounds.some((inbound) => inbound.inboundCode === item.inbound.inboundCode))
|
|
.map((item) => ({ cartonMark: item.cartonMark, pallet: pallets.get(item.inbound.inboundCode) }))
|
|
const fixturesConserved = overseasFixtures.length === expectedPending && overseasFixtures.every((item) => item.pallet)
|
|
context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', null, fixturesConserved,
|
|
`resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`, OPTIONS)
|
|
if (!fixturesConserved) {
|
|
blockerGap(context, 'OVERSEAS逐箱冻结事实',
|
|
`统一解析结果未完整覆盖本柜真实冻结箱码;resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`,
|
|
'fixture-mismatch')
|
|
return false
|
|
}
|
|
for (const [index, fixture] of overseasFixtures.entries()) {
|
|
const { cartonMark, pallet } = fixture
|
|
const body = { cartonMark, palletCode: pallet.palletCode,
|
|
idempotencyKey: key(context, `overseas-carton-${index}`) }
|
|
const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
|
|
if (!success(first.response)) return false
|
|
const replay = await pda(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键同载荷重放`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
|
|
context.assert(MODULE, `OVERSEAS逐箱${index + 1}幂等结果一致`, replay.response,
|
|
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
|
await expected(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键异载荷冲突`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
|
|
{ ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict)
|
|
await expected(context, token, 'POST', `OVERSEAS重复箱新键被拒绝${index + 1}`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
|
|
{ ...body, idempotencyKey: key(context, `overseas-duplicate-${index}`) }, conflict)
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function receiveDropship(context, token, producer, inbounds) {
|
|
const sourcePalletNos = Array.isArray(producer?.sealedSourcePalletNos)
|
|
? producer.sealedSourcePalletNos.map(id).filter(nonBlank) : []
|
|
const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0)
|
|
context.assert(MODULE, '一件代发来源托盘快照已从国内封托完整传递', null,
|
|
sourcePalletNos.length > 0 && new Set(sourcePalletNos).size === sourcePalletNos.length,
|
|
`sourcePallets=${sourcePalletNos.length}, pendingPackages=${expectedPending}`, OPTIONS)
|
|
if (!sourcePalletNos.length) return false
|
|
let accepted = 0
|
|
for (const [index, sourcePalletNo] of sourcePalletNos.entries()) {
|
|
const body = { sourcePalletNo, idempotencyKey: key(context, `dropship-pallet-${index}`) }
|
|
const first = await pda(context, token, 'POST', `一件代发整托实收${index + 1}`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body)
|
|
if (!success(first.response)) return false
|
|
accepted += Number(first.data?.acceptedPackages || 0)
|
|
const replay = await pda(context, token, 'POST', `一件代发整托${index + 1}同键同载荷重放`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body)
|
|
context.assert(MODULE, `一件代发整托${index + 1}幂等结果一致`, replay.response,
|
|
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
|
await expected(context, token, 'POST', `一件代发整托${index + 1}同键异载荷冲突`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
|
|
{ ...body, sourcePalletNo: `${sourcePalletNo}-MUTATED` }, conflict)
|
|
const duplicate = await pda(context, token, 'POST', `一件代发来源托盘新键重复收货${index + 1}`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
|
|
{ ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) })
|
|
context.assert(MODULE, `一件代发来源托盘重复收货不重复入账${index + 1}`, duplicate.response,
|
|
success(duplicate.response) && duplicate.data?.result === 'ALREADY_RECEIVED'
|
|
&& Number(duplicate.data?.acceptedPackages) === 0, trace(duplicate.response), OPTIONS)
|
|
}
|
|
context.assert(MODULE, '一件代发整托实收箱数与待收箱数守恒', null,
|
|
accepted === expectedPending, `accepted=${accepted}, expected=${expectedPending}`, OPTIONS)
|
|
return accepted === expectedPending
|
|
}
|
|
|
|
export async function awaitReceivingCandidate(context, token, producer, { attempts = 30, intervalMs = 1000,
|
|
delay = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
const containerNo = id(producer?.containerNo), bookingId = id(producer?.containerBookingId)
|
|
if (!nonBlank(containerNo) || !longId(bookingId) || !Number.isInteger(attempts) || attempts < 1 || attempts > 30
|
|
|| !Number.isInteger(intervalMs) || intervalMs < 0 || intervalMs > 1000) throw Error('Invalid exact receiving candidate scope or wait bound')
|
|
const proof = { containerNo, sourceContainerBookingId:bookingId, attempts:[], outcome:'PENDING' }
|
|
const resource = context.createdResources.phase4OverseasPda ||= {}
|
|
;(resource.candidateWaitEvidence ||= []).push(proof)
|
|
let result
|
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
result = await pda(context, token, 'GET', `查询本轮原柜候选收敛第${attempt}次`, '/api/pda/v1/last-leg/receiving-containers/candidates')
|
|
const rows = Array.isArray(result.data) ? result.data : []
|
|
const matchingCandidates = rows.filter(item => item.containerNo === containerNo && id(item.sourceContainerBookingId) === bookingId)
|
|
proof.attempts.push({ attempt, http:result.response?.http, code:code(result.response), requestId:result.response?.requestId,
|
|
traceId:result.response?.traceId, candidateCount:rows.length, exactMatches:matchingCandidates.length,
|
|
sameNumberCandidates:rows.filter(item => item.containerNo === containerNo).map(item => ({
|
|
containerNo:item.containerNo, sourceContainerBookingId:id(item.sourceContainerBookingId),
|
|
operable:item.operable, blockReason:item.blockReason })) })
|
|
if (!success(result.response) || !Array.isArray(result.data)) { proof.outcome='QUERY_FAILED'; break }
|
|
if (matchingCandidates.length > 1) { proof.outcome='AMBIGUOUS'; break }
|
|
if (matchingCandidates.length === 1 && matchingCandidates[0].operable === true) {
|
|
proof.outcome='READY'
|
|
return {...result,candidate:matchingCandidates[0]}
|
|
}
|
|
if (attempt < attempts) await delay(intervalMs)
|
|
}
|
|
if (proof.outcome === 'PENDING') proof.outcome='TIMED_OUT'
|
|
return {...result,candidate:null,reason:proof.outcome}
|
|
}
|
|
|
|
export async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true, candidateWait = {}) {
|
|
if (runAuthorizationProbes && context.token) {
|
|
const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, context.token, {})
|
|
context.addStep(MODULE, '平台管理员访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
|
}
|
|
if (runAuthorizationProbes && merchantToken) {
|
|
const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, merchantToken, {})
|
|
context.addStep(MODULE, '客户访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
|
}
|
|
const before = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
|
const noCurrent = notFound(before)
|
|
context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before),
|
|
`${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS)
|
|
if (success(before)) {
|
|
blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before)
|
|
return
|
|
}
|
|
const preferredContainer = id(preferredProducer?.containerNo)
|
|
|| context.createdResources?.phase3Loading?.containerNo
|
|
const preferredBookingId = id(preferredProducer?.containerBookingId || context.createdResources?.phase3Loading?.containerBookingId)
|
|
if (!nonBlank(preferredContainer) || !longId(preferredBookingId)) {
|
|
blockerGap(context, '本轮原柜候选身份', '缺少本轮containerNo或containerBookingId,禁止仅按柜号匹配历史柜', 'producer-chain')
|
|
return
|
|
}
|
|
const candidates = await awaitReceivingCandidate(context,token,
|
|
{containerNo:preferredContainer,containerBookingId:preferredBookingId},candidateWait)
|
|
const candidate = candidates.candidate
|
|
if (!candidate) {
|
|
blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`,
|
|
`本轮原柜候选未在有界等待内唯一可操作:${candidates.reason};禁止回退处理历史或其他柜`, 'producer-chain', candidates.response)
|
|
return
|
|
}
|
|
const containerNo = id(candidate?.containerNo)
|
|
context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response,
|
|
nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0,
|
|
`containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS)
|
|
if (!containerNo) return
|
|
const sourceContainerBookingId = id(candidate.sourceContainerBookingId)
|
|
if (!longId(sourceContainerBookingId)) {
|
|
blockerGap(context, '候选来源柜次身份', '候选未返回有效sourceContainerBookingId,不得仅按可复用柜号选柜')
|
|
return
|
|
}
|
|
const bookingQuery = `?sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}`
|
|
context.createdResources.phase4OverseasPda.containerNo = containerNo
|
|
const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}${bookingQuery}`)
|
|
if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return
|
|
const prepareBody = { idempotencyKey: key(context, 'prepare') }
|
|
const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话',
|
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare${bookingQuery}`, prepareBody)
|
|
if (!success(prepared.response)) return
|
|
const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放',
|
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare${bookingQuery}`, prepareBody)
|
|
context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response,
|
|
success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS)
|
|
if (secondaryToken) {
|
|
await expected(context, secondaryToken, 'POST', '第二仓库操作员抢占同柜返回409',
|
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare${bookingQuery}`,
|
|
{ idempotencyKey: key(context, 'secondary-prepare') }, conflict)
|
|
}
|
|
if (runAuthorizationProbes) {
|
|
const other = candidates.data.find((item) => item.containerNo !== containerNo
|
|
&& item.operable === true && item.allPlansReady === true && Number(item.totalPackages) > 0)
|
|
if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409',
|
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}?sourceContainerBookingId=${encodeURIComponent(other.sourceContainerBookingId)}`, undefined, conflict)
|
|
else blockerGap(context, '跨柜锁409', '本轮首柜作业前只有一个可收柜,无法在不伪造柜数据的前提下验证跨柜锁')
|
|
}
|
|
const photos = await uploadPhotos(context, token)
|
|
if (!photos) return
|
|
const signBody = { photos, idempotencyKey: key(context, 'sign') }
|
|
const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody)
|
|
if (!success(signed.response)) return
|
|
const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody)
|
|
context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response,
|
|
success(signReplay.response) && signReplay.data?.status === 'SIGNED'
|
|
&& PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS)
|
|
await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign',
|
|
{ ...signBody, photos: [...photos].reverse() }, conflict)
|
|
const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current')
|
|
if (!success(current.response)) return
|
|
const beforeResolve = receivingSnapshot(current.data)
|
|
// Single-container completion/cancellation continuation does not provision a second
|
|
// loaded source. The default full receiving chain still requires its cross-container proof.
|
|
await probeCartonResolveNegatives(context, token, current.data, runAuthorizationProbes)
|
|
const producerCartonMarks = preferredProducer?.cartonMarks?.length
|
|
? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]
|
|
const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks, preferredProducer)
|
|
if (!cartonFixtures) return
|
|
const afterResolve = await pda(context, token, 'GET', '箱码解析正负向后回查当前柜零写入',
|
|
'/api/pda/v1/last-leg/receiving-containers/current')
|
|
context.assert(MODULE, 'FBA与OVERSEAS箱码resolve均为纯查询且零状态写入', afterResolve.response,
|
|
success(afterResolve.response) && receivingSnapshot(afterResolve.data) === beforeResolve,
|
|
`before=${beforeResolve}, after=${receivingSnapshot(afterResolve.data)}; ${trace(afterResolve.response)}`, OPTIONS)
|
|
if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return
|
|
const pallets = new Map()
|
|
for (const [index, inbound] of current.data.inbounds.entries()) {
|
|
const pallet = await resolveGeneratedPallet(context, inbound)
|
|
if (!pallet) {
|
|
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}自动计划托盘`,
|
|
`按柜确认没有为该入仓码留下可解析的实体托盘;禁止在PDA收货时临时新建脱离计划的托盘`, 'warehouse-resource')
|
|
continue
|
|
}
|
|
pallets.set(inbound.inboundCode, pallet)
|
|
context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId)
|
|
const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`,
|
|
'/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode })
|
|
context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response,
|
|
success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS)
|
|
}
|
|
const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA')
|
|
const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS')
|
|
const strictDropship = preferredProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
|
|
if (!strictDropship && fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) {
|
|
const firstFba = fbaInbounds[0]
|
|
const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA'
|
|
&& item.inbound.inboundCode === firstFba.inboundCode)
|
|
if (!fbaCarton) {
|
|
blockerGap(context, `FBA入仓码${firstFba.inboundCode}箱码反证`, '没有冻结FBA箱码可用于验证carton收货精确A0409')
|
|
return
|
|
}
|
|
await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', {
|
|
cartonMark: fbaCarton?.cartonMark,
|
|
palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'),
|
|
}, (response) => a0409Message(response, /^FBA destination must use bulk count$/))
|
|
const groups = new Map()
|
|
for (const inbound of fbaInbounds) {
|
|
const fixture = cartonFixtures.find((item) => item.inbound.inboundCode === inbound.inboundCode)
|
|
const groupKey = fixture?.resolved?.groupKey || `INBOUND:${inbound.inboundCode}`
|
|
if (!groups.has(groupKey)) groups.set(groupKey, [])
|
|
groups.get(groupKey).push(inbound)
|
|
}
|
|
for (const [index, group] of [...groups.values()].entries()) {
|
|
await receiveFba(context, token, group, pallets.get(group[0].inboundCode), cartonFixtures, index)
|
|
}
|
|
} else if (!strictDropship && preferredProducer?.destinationType !== 'OVERSEAS') {
|
|
blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码')
|
|
}
|
|
if (!strictDropship && overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) {
|
|
const firstOverseas = overseasInbounds[0]
|
|
const pallet = pallets.get(firstOverseas.inboundCode)
|
|
await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
|
|
inboundCode: firstOverseas.inboundCode, palletCode: pallet.palletCode,
|
|
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
|
|
}, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/))
|
|
await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures)
|
|
} else if (!strictDropship && preferredProducer?.destinationType !== 'FBA') {
|
|
blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码')
|
|
}
|
|
if (strictDropship) {
|
|
const dropshipInbounds = [...fbaInbounds, ...overseasInbounds]
|
|
const firstInbound = dropshipInbounds[0]
|
|
const pallet = firstInbound ? pallets.get(firstInbound.inboundCode) : null
|
|
if (!firstInbound || !pallet) {
|
|
blockerGap(context, '一件代发整托链', '当前一件代发柜没有带预留实体托盘的入仓码')
|
|
return
|
|
}
|
|
if (firstInbound.destinationType === 'OVERSEAS') {
|
|
await expected(context, token, 'POST', '一件代发硬拒绝逐箱收货',
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', {
|
|
cartonMark: cartonFixtures[0]?.cartonMark,
|
|
palletCode: pallet.palletCode, idempotencyKey: key(context, 'dropship-carton-forbidden'),
|
|
}, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/))
|
|
} else {
|
|
await expected(context, token, 'POST', '一件代发硬拒绝按箱数收货',
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
|
|
inboundCode: firstInbound.inboundCode, palletCode: pallet.palletCode,
|
|
packageCount: 1, idempotencyKey: key(context, 'dropship-bulk-forbidden'),
|
|
}, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/))
|
|
}
|
|
await receiveDropship(context, token, preferredProducer, dropshipInbounds)
|
|
}
|
|
const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current')
|
|
if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) {
|
|
blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response)
|
|
return
|
|
}
|
|
const completeBody = { idempotencyKey: key(context, 'complete') }
|
|
const sourceInboundIds = [...new Set([id(preferredProducer?.sourceInboundId),
|
|
...(preferredProducer?.palletLabelManifest?.groups || []).flatMap(group => (group.labels || []).map(label => id(label.inboundId)))].filter(longId))].sort()
|
|
const scope = { customerId: id(preferredProducer?.tenantId), warehouseId: id(refreshed.data?.overseasWarehouseId),
|
|
bookingId: sourceContainerBookingId, containerNo,
|
|
inboundCodes: refreshed.data.inbounds.map(inbound => inbound.inboundCode).sort(), sourceInboundIds,
|
|
expectedPackagesByInbound: Object.fromEntries(refreshed.data.inbounds.map(inbound => [inbound.inboundCode, Number(inbound.totalPackages)])),
|
|
expectedTransitPackages: refreshed.data.inbounds.filter(inbound => inbound.orderType === 'FIRST_LEG').reduce((sum, inbound) => sum + Number(inbound.totalPackages), 0),
|
|
completeKey: completeBody.idempotencyKey, freshKey: key(context, 'complete-fresh-repeat') }
|
|
const receivingProof = { scope, stages: {}, proof: null, status: 'UNPROVEN' }
|
|
context.createdResources.phase4OverseasPda.receivingCompletionEvidence ||= {}
|
|
context.createdResources.phase4OverseasPda.receivingCompletionEvidence[sourceContainerBookingId] = receivingProof
|
|
if (typeof context.context.beforeReceivingComplete === 'function') {
|
|
await context.context.beforeReceivingComplete(scope)
|
|
}
|
|
await captureReceivingEvidence(context, scope, 'BEFORE_COMPLETE', receivingProof)
|
|
const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
|
|
await captureReceivingEvidence(context, scope, 'AFTER_COMPLETE', receivingProof)
|
|
if (!success(completed.response)) return
|
|
const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
|
|
context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response,
|
|
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED' && sameEvidence(completeReplay.data, completed.data), trace(completeReplay.response), OPTIONS)
|
|
await captureReceivingEvidence(context, scope, 'AFTER_SAME_KEY_REPLAY', receivingProof)
|
|
const freshComplete = await expected(context, token, 'POST', '已完成柜新键重复完成被拒绝且不能再次写库存',
|
|
'/api/pda/v1/last-leg/receiving-containers/current/complete', { idempotencyKey: scope.freshKey },
|
|
response => a0404Message(response, /^Current receiving container session was not found$/))
|
|
await captureReceivingEvidence(context, scope, 'AFTER_FRESH_KEY_REJECTION', receivingProof)
|
|
receivingProof.proof = receivingCompletionProof(scope, Object.fromEntries(Object.entries(receivingProof.stages).map(([stage, row]) => [stage, row.evidence])))
|
|
receivingProof.commandResults = { firstCompleteSucceeded: success(completed.response), sameKeyReturnedExactOriginal: success(completeReplay.response) && sameEvidence(completeReplay.data, completed.data),
|
|
freshKeyRejected: a0404Message(freshComplete, /^Current receiving container session was not found$/) }
|
|
receivingProof.status = receivingProof.proof.passed && Object.values(receivingProof.commandResults).every(Boolean) ? 'VERIFIED' : 'UNPROVEN'
|
|
for (const [check, passed] of Object.entries(receivingProof.proof.checks)) context.assert(MODULE, `收货完成库存守恒 ${check}`, null, passed,
|
|
`bookingId=${sourceContainerBookingId}, proofStatus=${receivingProof.status}`, OPTIONS)
|
|
if (typeof context.context.afterReceivingComplete === 'function') {
|
|
if (receivingProof.status !== 'VERIFIED') throw new Error('收货完成库存证据未闭合,禁止继续故障恢复验收')
|
|
const eventRecovery = await context.context.afterReceivingComplete(scope)
|
|
if (eventRecovery) {
|
|
receivingProof.eventRecovery = eventRecovery
|
|
const afterRecovery = await captureReceivingEvidence(context, scope, 'AFTER_EVENT_RECOVERY', receivingProof)
|
|
const unchanged = eventRecovery.restored === true && receivingEventRecoveryInventoryUnchanged(scope,
|
|
receivingProof.stages.AFTER_FRESH_KEY_REJECTION.evidence, afterRecovery)
|
|
receivingProof.eventRecoveryInventoryUnchanged = unchanged
|
|
context.assert(MODULE, '原卸柜事件DEAD恢复后库存位置托盘组成及不可变流水零漂移', null, unchanged,
|
|
`bookingId=${sourceContainerBookingId}`, OPTIONS)
|
|
if (!unchanged) throw new Error('卸柜事件恢复后库存证据变化或服务恢复未验证')
|
|
}
|
|
}
|
|
const after = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
|
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
|
|
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
|
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
|
|
const physicalPallets = [...new Map([...pallets.values()]
|
|
.map((item) => [id(item.palletId), item])).values()]
|
|
const generatedContainerPallets = current.data.inbounds.flatMap((item) =>
|
|
context.createdResources.phase4OverseasPda.generatedPalletsByInbound[item.inboundCode] || [])
|
|
const completedFixtures = []
|
|
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
|
|
const producerFixtures = (context.createdResources?.phase4InboundProducerFixtures?.containers || [])
|
|
.filter((item) => id(item?.containerNo) === id(containerNo))
|
|
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
|
|
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
|
|
pallets.get(inbound.inboundCode), inbound.destinationType)
|
|
if (warehouseFacts && context.context?.phase4OverseasPdaOperations) {
|
|
const producerFixture = producerFixtures.find((item) => id(item?.inboundCode) === id(inbound.inboundCode))
|
|
|| producerFixtures.find((item) => item?.destinationType === inbound.destinationType
|
|
&& id(item?.destinationWarehouseCode) === id(inbound?.fbaWarehouseCode))
|
|
|| producerFixtures.find((item) => item?.destinationType === inbound.destinationType)
|
|
const fixture = {
|
|
containerNo, order, destinationType: inbound.destinationType,
|
|
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
|
|
resourceKey: producerFixture?.resourceKey || null,
|
|
auxiliaryPallets: generatedContainerPallets.filter((candidate) =>
|
|
id(candidate?.palletId) !== id(warehouseFacts.pallet?.palletId)),
|
|
...warehouseFacts,
|
|
}
|
|
context.context.phase4OverseasPdaOperations.completedFixtures.push(fixture)
|
|
completedFixtures.push(fixture)
|
|
}
|
|
}
|
|
const receivedPallets = [...new Map(completedFixtures.map((fixture) => {
|
|
const palletId = id(fixture.pallet?.palletId)
|
|
const source = physicalPallets.find((candidate) => id(candidate?.palletId) === palletId)
|
|
return [palletId, { palletId, unitPrice: Number(source?.unitPrice || 0) }]
|
|
}).filter(([palletId]) => longId(palletId))).values()]
|
|
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
|
|
containerNo,
|
|
containerBookingId: id(preferredProducer?.containerBookingId),
|
|
expectedPalletCost: receivedPallets.reduce((sum, item) => sum + (item.unitPrice > 0 ? item.unitPrice : 10), 0),
|
|
palletIds: receivedPallets.map((item) => item.palletId),
|
|
})
|
|
}
|
|
}
|
|
|
|
export async function run(context) {
|
|
context.createdResources.phase4OverseasPda = {
|
|
marker: context.marker, containerNo: null, customerId: null, orderId: null,
|
|
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [],
|
|
completedContainerFacts: [],
|
|
overseasWarehouseId: null, overseasWarehouseName: null,
|
|
generatedPalletsByInbound: {},
|
|
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
|
|
}
|
|
const merchantToken = await loginMerchantForNegativeProbe(context)
|
|
const pickerAccess = await resolvePickerAccess(context)
|
|
if (!pickerAccess) return
|
|
const sharedWarehouse = await resolveSharedWarehouse(context)
|
|
if (!sharedWarehouse) {
|
|
blockerGap(context, '海外PDA共享实际收货仓', '国内Warehouse主数据没有ACTIVE且启用RECEIVING的OVERSEAS固定作业仓')
|
|
return
|
|
}
|
|
context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id
|
|
context.createdResources.phase4OverseasPda.overseasWarehouseName = sharedWarehouse.name
|
|
const primary = await createPicker(context, 1, sharedWarehouse.id, pickerAccess)
|
|
const secondary = primary?.token ? await createPicker(context, 2, sharedWarehouse.id, pickerAccess) : null
|
|
for (const picker of [primary, secondary]) if (picker?.membershipId) {
|
|
context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId)
|
|
}
|
|
let handoffReady = false
|
|
const cleanup = async () => {
|
|
await disablePicker(context, secondary, 2)
|
|
await disablePicker(context, primary, 1)
|
|
}
|
|
context.context.phase4OverseasPdaOperations = {
|
|
marker: context.marker,
|
|
primary: pickerSessionHandoff(primary),
|
|
secondary: pickerSessionHandoff(secondary),
|
|
overseasWarehouseId: sharedWarehouse.id,
|
|
merchantToken,
|
|
completedFixtures: [],
|
|
cleanup,
|
|
}
|
|
try {
|
|
if (!primary?.token || !secondary?.token) {
|
|
blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个OVERSEAS_WAREHOUSE_PDA_OPERATOR平台账号,无法验证全局一柜锁')
|
|
return
|
|
}
|
|
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
|
const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
|
const standardFbaProducers = (Array.isArray(matrix?.containers) ? matrix.containers : [])
|
|
.filter((item) => item?.destinationType === 'FBA')
|
|
.filter((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
|
.filter((item) => /-P4-(?:FBA-STANDARD|FBA-SECOND|FBA-OPERATIONS|FBA-ROUTE)(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
|
|
.sort((left, right) => String(left?.resourceKey).localeCompare(String(right?.resourceKey)))
|
|
.slice(0, 4)
|
|
// The sealed dropship pallet proves the indivisible handover. A separate standard FBA
|
|
// fixture supplies multiple cartons for reversible operations. The second standard FBA
|
|
// fixture remains available for the multi-destination dispatch and delivery split proof.
|
|
const producers = [dropship, ...standardFbaProducers,
|
|
matrix?.byDestinationType?.OVERSEAS?.[0]]
|
|
.filter(uniqueContainerProducer)
|
|
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
|
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
|
|
if (producers.length && !(await convertReceivingContainers(context, producers))) {
|
|
blockerGap(context, '实际处理柜整柜转换门禁',
|
|
'目标柜未能通过正式柜级预览与确认形成逐入仓码计划及实体托盘')
|
|
return
|
|
}
|
|
if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) {
|
|
blockerGap(context, '实际处理柜PICKUP_READY门禁',
|
|
'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜')
|
|
return
|
|
}
|
|
if (producers.length && context.options?.modules?.includes('phase5-terminal-drayage')
|
|
&& !(await prepareTerminalDrayage(context, producers[0]))) {
|
|
blockerGap(context, '目的港码头拖车提柜前置',
|
|
'本轮首个PICKUP_READY货柜未能在卸柜前完成选车队、预约和实际提柜')
|
|
return
|
|
}
|
|
for (const [index, producer] of (producers.length ? producers : [null]).entries()) {
|
|
context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}`
|
|
context.createdResources.phase4OverseasPda.finalStatus = null
|
|
await runReceiving(context, primary.token, secondary.token,
|
|
index === 0 ? merchantToken : null, producer, index === 0)
|
|
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
|
|
context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null)
|
|
}
|
|
}
|
|
const refreshedMerchantToken = await loginMerchantForNegativeProbe(context)
|
|
if (nonBlank(refreshedMerchantToken)) {
|
|
context.context.phase4OverseasPdaOperations.merchantToken = refreshedMerchantToken
|
|
}
|
|
handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0
|
|
} finally {
|
|
delete context.pdaContainerSuffix
|
|
if (!handoffReady) {
|
|
try { await cleanup() }
|
|
finally { delete context.context.phase4OverseasPdaOperations }
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function resolveSharedWarehouse(context) {
|
|
const path = '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE'
|
|
const response = await context.request('GET', path)
|
|
const rows = records(response?.body?.data)
|
|
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
|
|
const warehouse = success(response) ? rows.find((item) => fixedIds.has(id(item?.id))) : null
|
|
context.addStep(MODULE, '读取国内Warehouse权威海外PDA实际收货仓', response,
|
|
Boolean(warehouse) && warehouse?.warehouseType === 'OVERSEAS' && warehouse?.status === 'ACTIVE',
|
|
`warehouseId=${id(warehouse?.id) || 'missing'}, warehouseName=${warehouse?.warehouseName || warehouse?.name || 'missing'}; ${trace(response)}`, OPTIONS)
|
|
return warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
|
|
}
|