test: integrate reliable event recovery and receiving evidence
This commit is contained in:
@@ -99,7 +99,8 @@ export function receivingEvidenceValid(snapshot, scope) {
|
||||
|| !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)
|
||||
&& 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 =>
|
||||
@@ -116,6 +117,7 @@ export function receivingEvidenceValid(snapshot, scope) {
|
||||
|| !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) {
|
||||
@@ -220,6 +222,11 @@ export async function captureReceivingEvidence(context, scope, stage, target) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -339,7 +346,17 @@ export async function createPicker(context, index, overseasWarehouseId, access)
|
||||
}
|
||||
context.addStep(MODULE, `海外PDA操作员${index}安全投影已收敛`, projectionResponse, projectionReady,
|
||||
`attempts=${projectionAttempt + 1}; ${trace(projectionResponse)}`, OPTIONS)
|
||||
return { membershipId, username, token: projectionReady ? token : null }
|
||||
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) {
|
||||
@@ -827,7 +844,37 @@ async function receiveDropship(context, token, producer, inbounds) {
|
||||
return accepted === expectedPending
|
||||
}
|
||||
|
||||
export async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) {
|
||||
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)
|
||||
@@ -844,20 +891,19 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
|
||||
blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before)
|
||||
return
|
||||
}
|
||||
const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates')
|
||||
if (!Array.isArray(candidates.data) || candidates.data.length === 0) {
|
||||
blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response)
|
||||
return
|
||||
}
|
||||
const preferredContainer = id(preferredProducer?.containerNo)
|
||||
|| context.createdResources?.phase3Loading?.containerNo
|
||||
const preferredBookingId = id(preferredProducer?.containerBookingId)
|
||||
const matchingCandidates = candidates.data.filter(item => item.containerNo === preferredContainer
|
||||
&& (!preferredBookingId || id(item.sourceContainerBookingId) === preferredBookingId))
|
||||
const candidate = matchingCandidates.length === 1 ? matchingCandidates[0] : null
|
||||
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'}候选`,
|
||||
'三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response)
|
||||
`本轮原柜候选未在有界等待内唯一可操作:${candidates.reason};禁止回退处理历史或其他柜`, 'producer-chain', candidates.response)
|
||||
return
|
||||
}
|
||||
const containerNo = id(candidate?.containerNo)
|
||||
@@ -1015,6 +1061,9 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
|
||||
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)
|
||||
@@ -1033,6 +1082,20 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
|
||||
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
|
||||
@@ -1110,8 +1173,8 @@ export async function run(context) {
|
||||
}
|
||||
context.context.phase4OverseasPdaOperations = {
|
||||
marker: context.marker,
|
||||
primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null,
|
||||
secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null,
|
||||
primary: pickerSessionHandoff(primary),
|
||||
secondary: pickerSessionHandoff(secondary),
|
||||
overseasWarehouseId: sharedWarehouse.id,
|
||||
merchantToken,
|
||||
completedFixtures: [],
|
||||
@@ -1127,9 +1190,9 @@ export async function run(context) {
|
||||
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)(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
|
||||
.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, 3)
|
||||
.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.
|
||||
|
||||
Reference in New Issue
Block a user