test: strengthen customs and fulfillment acceptance boundaries
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
|
||||
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
||||
|
||||
const MODULE = 'phase5-overseas-fulfillment'
|
||||
const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit'
|
||||
@@ -66,6 +67,221 @@ async function poll(action, predicate, attempts = 45, intervalMs = 1_000) {
|
||||
return { result, attempts }
|
||||
}
|
||||
|
||||
async function allPages(context, path, values, name) {
|
||||
const rows = []
|
||||
for (let page = 1; page <= 50; page += 1) {
|
||||
const current = await platform(context, 'GET', `${name}第${page}页`, query(path, { ...values, page, size: 200 }))
|
||||
if (!success(current.response) || !Array.isArray(current.data?.records)) return null
|
||||
rows.push(...current.data.records)
|
||||
const total = Number(current.data.total)
|
||||
if (Number.isInteger(total) && rows.length >= total) {
|
||||
const complete = rows.length === total && rows.every(row => nonBlank(id(row.id)))
|
||||
&& new Set(rows.map(row => id(row.id))).size === rows.length
|
||||
context.assert(MODULE, `${name}无分页遗漏或重复`, current.response,
|
||||
complete,
|
||||
`rows=${rows.length}, total=${total}`, OPTIONS)
|
||||
return complete ? rows : null
|
||||
}
|
||||
if (current.data.records.length === 0) break
|
||||
}
|
||||
blocker(context, name, '分页总数缺失或未读完,不将截断记录当作完整库存证据')
|
||||
return null
|
||||
}
|
||||
|
||||
export function inventoryQuantitySnapshot(rows) {
|
||||
return (rows || []).map(row => ({ id: id(row.id), inboundCode: row.inboundCode,
|
||||
totalPackages: Number(row.totalPackages), availablePackages: Number(row.availablePackages),
|
||||
holdPackages: Number(row.holdPackages), allocatedPackages: Number(row.allocatedPackages),
|
||||
pickedPackages: Number(row.pickedPackages) })).sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) {
|
||||
const rows = []
|
||||
for (const inboundCode of [...new Set(inboundCodes)]) {
|
||||
const current = await allPages(context, `${LASTLEG_BASE}/inventory/balances`,
|
||||
{ customerId, overseasWarehouseId: warehouseId, inboundCode }, `${name}-${inboundCode}`)
|
||||
if (!current) return null
|
||||
// The server's text filter may match child codes; inventory evidence must match the exact ticket.
|
||||
rows.push(...current.filter(row => row.inboundCode === inboundCode
|
||||
&& id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId))
|
||||
}
|
||||
return inventoryQuantitySnapshot(rows)
|
||||
}
|
||||
|
||||
async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) {
|
||||
const rows = await allPages(context, `${LASTLEG_BASE}/inventory/ledger`,
|
||||
{ customerId, outboundOrderNo: outboundNo, businessType, businessOrderNo }, name)
|
||||
if (!rows) return null
|
||||
const exact = rows.filter(row => id(row.customerId) === customerId
|
||||
&& row.outboundOrderNo === outboundNo && row.businessType === businessType
|
||||
&& (!businessOrderNo || row.businessOrderNo === businessOrderNo))
|
||||
context.assert(MODULE, `${name}只属于本轮实际出库来源`, null, exact.length === rows.length,
|
||||
`rows=${rows.length}, exact=${exact.length}, outboundNo=${outboundNo}`, OPTIONS)
|
||||
return exact.map(row => ({ id: id(row.id), inboundCode: row.inboundCode,
|
||||
palletNo: row.palletNo, deltaPackages: Number(row.deltaPackages),
|
||||
beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0)
|
||||
const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right)
|
||||
|
||||
// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof.
|
||||
export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) {
|
||||
if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode)
|
||||
|| !Array.isArray(pendingInboundCodes) || pendingInboundCodes.length === 0
|
||||
|| pendingInboundCodes.includes(deliveredInboundCode)) {
|
||||
blocker(context, '同Order多有效入仓分片', '缺少同一订单一片已签收及另一有效片未签收的本轮资源')
|
||||
return false
|
||||
}
|
||||
const rows = []
|
||||
for (const inboundCode of [deliveredInboundCode, ...pendingInboundCodes]) {
|
||||
const current = await platform(context, 'GET', `核对同Order分片${inboundCode}`,
|
||||
query('/api/platform/inbounds', { tenantId: customerId, inboundCode, page: 1, size: 200 }))
|
||||
rows.push(current.data?.records?.find(row => row.inboundCode === inboundCode))
|
||||
}
|
||||
const sameOrder = rows.every(row => row && id(row.orderId) === id(orderId))
|
||||
const validPartialCompletion = sameOrder && rows[0]?.processStatus === 'DELIVERED'
|
||||
&& rows.slice(1).every(row => nonBlank(row.processStatus) && !['DELIVERED', 'CANCELLED'].includes(row.processStatus))
|
||||
context.assert(MODULE, '多分片完成门禁使用同一Order且各入仓码有效', null,
|
||||
validPartialCompletion,
|
||||
`codes=${[deliveredInboundCode, ...pendingInboundCodes].join(',')}`, OPTIONS)
|
||||
if (!validPartialCompletion) return false
|
||||
const current = await platform(context, 'GET', '未全签收时回查订单完成门禁',
|
||||
query(`/api/platform/v1/orders/${encodeURIComponent(id(orderId))}`, { tenantId: customerId }))
|
||||
const blocked = nonBlank(current.data?.summary?.mainStatus) && current.data.summary.mainStatus !== 'COMPLETED'
|
||||
context.assert(MODULE, '同一Order仍有有效未签收分片不得提前COMPLETED', current.response,
|
||||
blocked, `mainStatus=${current.data?.summary?.mainStatus || 'missing'}`, OPTIONS)
|
||||
return sameOrder && blocked
|
||||
}
|
||||
|
||||
export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) {
|
||||
const owned = context.createdResources?.phase5OverseasFulfillment
|
||||
if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) {
|
||||
blocker(context, '同派送退回异常实收归属', '必须复用本轮createdResources内的原派送退回异常和原出库号')
|
||||
return null
|
||||
}
|
||||
const exceptionPath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}`, { customerId })
|
||||
const before = await platform(context, 'GET', '读取本轮派送退回异常冻结来源', exceptionPath)
|
||||
const order = before.data?.order
|
||||
const sources = before.data?.returnSources
|
||||
if (!success(before.response) || !['PENDING', 'PROCESSING'].includes(order?.status) || order?.exceptionType !== 'RETURN'
|
||||
|| order?.outboundNo !== outboundNo || !Array.isArray(sources) || sources.length !== 1) {
|
||||
blocker(context, '同派送退回异常实收', '需要本轮PENDING/PROCESSING退回异常及唯一原出库来源,不能借普通退货单替代', before.response)
|
||||
return null
|
||||
}
|
||||
const continuing = order.status === 'PROCESSING'
|
||||
const previousAttempt = resume || owned.returnReceiptAttempt
|
||||
if (continuing && (!/^\d+$/.test(id(previousAttempt?.targetPalletId))
|
||||
|| !nonBlank(previousAttempt?.targetPalletNo) || !/^\d+$/.test(id(previousAttempt?.evidenceFileId))
|
||||
|| (previousAttempt.exceptionNo && previousAttempt.exceptionNo !== exceptionNo)
|
||||
|| (previousAttempt.outboundNo && previousAttempt.outboundNo !== outboundNo))) {
|
||||
blocker(context, '同派送退回实收续验资料', 'PROCESSING续验必须保留原目标托盘与已上传证据文件ID,不重复开始或重新建资源', before.response)
|
||||
return null
|
||||
}
|
||||
const source = sources[0]
|
||||
const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存')
|
||||
const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo)
|
||||
if (!stockBefore || !ledgerBefore) return null
|
||||
const resourceBase = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}`
|
||||
const palletCode = continuing ? previousAttempt.targetPalletNo
|
||||
: `RET-${context.marker}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128)
|
||||
let targetPalletId = continuing ? id(previousAttempt.targetPalletId) : ''
|
||||
if (!continuing) {
|
||||
const created = await platform(context, 'POST', '创建本轮派送退回实收目标托盘', `${resourceBase}/pallets`,
|
||||
{ palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: `${context.marker}-delivery-return` })
|
||||
targetPalletId = id(created.data)
|
||||
if (!success(created.response) || !/^\d+$/.test(targetPalletId)) return null
|
||||
}
|
||||
const pallet = await platform(context, 'GET', '回查退回目标托盘实际库位', `${resourceBase}/pallets/${targetPalletId}`)
|
||||
const locations = await allPages(context, `${resourceBase}/locations`, {}, '读取退回目标托盘库位')
|
||||
const location = locations?.find(row => id(row.id) === id(pallet.data?.locationId))
|
||||
context.assert(MODULE, '退回实收目标托盘和库位属于同一海外仓', pallet.response,
|
||||
id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode
|
||||
&& Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS)
|
||||
if (!location?.locationCode) return null
|
||||
let processingVersion = Number(order.version)
|
||||
let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : ''
|
||||
if (!continuing) {
|
||||
const startBody = { expectedVersion: Number(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' }
|
||||
const started = await platform(context, 'POST', '开始处理同一派送退回异常',
|
||||
query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody)
|
||||
if (!success(started.response) || started.data?.status !== 'PROCESSING') return null
|
||||
processingVersion = Number(started.data.version)
|
||||
const evidence = await context.requestMultipart('POST',
|
||||
query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }),
|
||||
{ name: `${context.marker}-return-receipt.txt`, type: 'text/plain',
|
||||
content: new TextEncoder().encode(`${context.marker} synthetic return receipt evidence ${exceptionNo}`) })
|
||||
const uploaded = context.apiSuccess(MODULE, '上传同派送退回异常实收证据', evidence, trace(evidence), OPTIONS)
|
||||
if (!success(evidence) || !/^\d+$/.test(id(uploaded?.fileId))) return null
|
||||
evidenceFileId = id(uploaded.fileId)
|
||||
}
|
||||
const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey)
|
||||
? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE')
|
||||
if (!Number.isInteger(processingVersion) || processingVersion < 0) {
|
||||
blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response)
|
||||
return null
|
||||
}
|
||||
owned.returnReceiptAttempt = { exceptionNo, outboundNo, targetPalletId, targetPalletNo: palletCode,
|
||||
evidenceFileId, completeIdempotencyKey }
|
||||
const completeBody = { expectedVersion: processingVersion, idempotencyKey: completeIdempotencyKey,
|
||||
handlingResult: `${context.marker}-实际退回一箱并恢复来源库存`, evidenceFileIds: [evidenceFileId],
|
||||
receipts: [{ outboundPalletItemId: id(source.outboundPalletItemId), inventoryPositionId: id(source.inventoryPositionId),
|
||||
receivedPackages: 1, targetPalletId, targetPalletNo: palletCode,
|
||||
targetLocationId: id(location.id), targetLocationCode: location.locationCode }] }
|
||||
const completePath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/complete`, { customerId })
|
||||
const completed = await platform(context, 'POST', '同派送退回异常完成实收及恢复库存', completePath, completeBody)
|
||||
context.assert(MODULE, '同派送退回异常完成实收恰好恢复一箱', completed.response,
|
||||
completed.data?.status === 'COMPLETED' && Number(completed.data?.restoredPackages) === 1
|
||||
&& /^\d+$/.test(id(completed.data?.inventoryAdjustmentId)), trace(completed.response), OPTIONS)
|
||||
if (!success(completed.response)) {
|
||||
const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存')
|
||||
const ledgerAfterFailure = await shipmentLedger(context, customerId, outboundNo, '退回实收失败后流水', 'RETURN_STOCK_IN', exceptionNo)
|
||||
context.assert(MODULE, '退回实收失败后库存及流水保持不变并保留原续验资料', completed.response,
|
||||
sameSnapshot(stockBefore, stockAfterFailure) && sameSnapshot(ledgerBefore, ledgerAfterFailure), trace(completed.response), OPTIONS)
|
||||
return null
|
||||
}
|
||||
const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存')
|
||||
const ledgerAfter = await shipmentLedger(context, customerId, outboundNo, '退回实收后流水', 'RETURN_STOCK_IN', exceptionNo)
|
||||
const targetPositions = []
|
||||
for (const balance of stockAfter || []) {
|
||||
const positions = await platform(context, 'GET', '回查退回目标托盘实际库存位置',
|
||||
query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balance.id)}/positions`, { customerId, palletNo: palletCode }))
|
||||
if (Array.isArray(positions.data)) targetPositions.push(...positions.data.filter(row => row.palletNo === palletCode))
|
||||
}
|
||||
context.assert(MODULE, '退回一箱实际落到新目标托盘和原票库存位置', null,
|
||||
targetPositions.length === 1 && targetPositions[0].inboundCode === order.inboundCode
|
||||
&& id(targetPositions[0].palletId) === targetPalletId && id(targetPositions[0].warehouseLocationId) === id(location.id)
|
||||
&& id(targetPositions[0].customerId) === customerId && id(targetPositions[0].overseasWarehouseId) === warehouseId
|
||||
&& Number(targetPositions[0].availablePackages) === 1 && Number(targetPositions[0].totalPackages) === 1,
|
||||
`targetPositionCount=${targetPositions.length}, targetPallet=${palletCode}`, OPTIONS)
|
||||
const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id))
|
||||
context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null,
|
||||
stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1
|
||||
&& newRows?.length === 1 && newRows[0].deltaPackages === 1
|
||||
&& newRows[0].afterPackages - newRows[0].beforePackages === 1,
|
||||
`before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS)
|
||||
const replay = await platform(context, 'POST', '同派送退回实收完成原键重放', completePath, completeBody)
|
||||
const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存')
|
||||
const ledgerReplay = await shipmentLedger(context, customerId, outboundNo, '退回重放后流水', 'RETURN_STOCK_IN', exceptionNo)
|
||||
context.assert(MODULE, '退回实收重放不二次恢复库存或增加流水', replay.response,
|
||||
replay.data?.replayed === true && id(replay.data?.inventoryAdjustmentId) === id(completed.data?.inventoryAdjustmentId)
|
||||
&& sameSnapshot(stockAfter, stockReplay) && sameSnapshot(ledgerAfter, ledgerReplay), trace(replay.response), OPTIONS)
|
||||
const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath)
|
||||
const delivery = await platform(context, 'GET', '回查退回实收后的原派送单',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId }))
|
||||
context.assert(MODULE, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response,
|
||||
final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo
|
||||
&& final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId)
|
||||
&& final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId)
|
||||
&& id(row.inventoryPositionId) === id(source.inventoryPositionId))
|
||||
&& delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo,
|
||||
trace(final.response), OPTIONS)
|
||||
return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId,
|
||||
inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages),
|
||||
targetPositionIds: targetPositions.map(row => id(row.id)) }
|
||||
}
|
||||
|
||||
export async function saveReceiptAndComplete(context, delivery, customerId, index) {
|
||||
const deliveryNo = id(delivery.deliveryNo)
|
||||
const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId })
|
||||
@@ -86,6 +302,11 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
|
||||
context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse,
|
||||
/^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS)
|
||||
if (!success(uploadResponse) || !fileId) return null
|
||||
if (context.createdResources.phase5OverseasFulfillment) {
|
||||
const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= []
|
||||
receipts.push({ deliveryNo, fileId,
|
||||
content: `${context.marker} receipt acceptance for ${deliveryNo}` })
|
||||
}
|
||||
|
||||
const existing = (before.data?.categories || []).map((category) => ({
|
||||
category: category.category,
|
||||
@@ -115,6 +336,112 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
|
||||
return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null
|
||||
}
|
||||
|
||||
async function receiptBinary(context, token, path) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: '*/*' }, signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let body = null
|
||||
if (/json/i.test(contentType)) {
|
||||
try { body = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* keep non-JSON failure evidence */ }
|
||||
}
|
||||
return { http: response.status, body, bytes, contentType,
|
||||
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenOrForbidden = response => [403, 404].includes(response?.http)
|
||||
|| (response?.http === 200 && ['403', '404', 'A0403', 'A0404'].includes(code(response)))
|
||||
|
||||
export async function createSignedDocumentIsolationCustomer(context, { create = createMerchant } = {}) {
|
||||
// Own options, credentials and resource storage prevent the secondary fixture from changing the main customer.
|
||||
const secondary = Object.create(context)
|
||||
secondary.marker = `${context.marker}-SIGNED-B`
|
||||
secondary.options = { ...context.options, customerName: '' }
|
||||
secondary.context = { ...context.context }
|
||||
secondary.createdResources = {}
|
||||
await create(secondary, { publishEnvironment: false })
|
||||
const fixture = secondary.context?.merchantQualificationFixture
|
||||
const safe = secondary.createdResources?.merchantQualificationFixture
|
||||
const metadata = { tenantId: id(safe?.tenantId), tenantName: safe?.tenantName,
|
||||
username: safe?.username, marker: safe?.marker }
|
||||
const ready = fixture?.marker === secondary.marker && metadata.marker === secondary.marker
|
||||
&& /^\d+$/.test(metadata.tenantId) && metadata.tenantId === id(fixture?.tenantId)
|
||||
&& nonBlank(fixture?.username) && nonBlank(fixture?.password)
|
||||
context.assert(MODULE, '签收资料跨企业验收具有独立SIGNED-B本轮客户', null,
|
||||
ready, `fixtureMarker=${metadata.marker || 'missing'}, customerIdPresent=${Boolean(metadata.tenantId)}`, OPTIONS)
|
||||
if (!ready) {
|
||||
blocker(context, 'SIGNED-B客户会话', '独立隔离客户前置创建不完整,不使用主客户替代')
|
||||
return null
|
||||
}
|
||||
context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer = metadata
|
||||
const login = await context.request('POST', '/api/auth/login',
|
||||
{ loginId: fixture.username, password: fixture.password }, '')
|
||||
const auth = context.apiSuccess(MODULE, '独立SIGNED-B客户登录用于签收资料隔离', login, trace(login), OPTIONS)
|
||||
if (!success(login) || !nonBlank(auth?.accessToken)) {
|
||||
blocker(context, 'SIGNED-B客户会话', '独立客户登录未返回有效会话', login)
|
||||
return null
|
||||
}
|
||||
context.context.phase5TailVerification = { ...context.context.phase5TailVerification,
|
||||
foreignMerchantToken: auth.accessToken }
|
||||
return auth.accessToken
|
||||
}
|
||||
|
||||
export async function verifyCustomerSignedDocuments(context, merchantToken, receiptFiles, foreignToken = null) {
|
||||
if (!nonBlank(merchantToken) || !Array.isArray(receiptFiles) || receiptFiles.length === 0) {
|
||||
blocker(context, '客户完成后签收资料', '缺少本轮真实已绑定签收文件与客户会话')
|
||||
return false
|
||||
}
|
||||
let verified = true
|
||||
if (foreignToken) {
|
||||
const own = await merchant(context, merchantToken, 'GET', '签收文件隔离核对本客户会话身份', '/api/merchant/v1/iam/tenant/profile')
|
||||
const foreign = await merchant(context, foreignToken, 'GET', '签收文件隔离核对另一客户会话身份', '/api/merchant/v1/iam/tenant/profile')
|
||||
const distinct = /^\d+$/.test(id(own.data?.id)) && /^\d+$/.test(id(foreign.data?.id)) && id(own.data.id) !== id(foreign.data.id)
|
||||
context.assert(MODULE, '签收文件隔离必须使用两个不同企业的真实客户会话', foreign.response,
|
||||
distinct, `distinctCustomerIdentity=${distinct}`, OPTIONS)
|
||||
if (!distinct) return false
|
||||
}
|
||||
for (const receipt of receiptFiles) {
|
||||
const base = `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(receipt.deliveryNo)}/documents/receipt`
|
||||
const detail = await merchant(context, merchantToken, 'GET', `客户读取已完成派送${receipt.deliveryNo}签收资料`, base)
|
||||
const bound = detail.data?.deliveryNo === receipt.deliveryNo
|
||||
&& detail.data?.files?.some(file => id(file.fileId) === id(receipt.fileId))
|
||||
context.assert(MODULE, '客户签收只读资料返回本单已绑定文件', detail.response, bound, trace(detail.response), OPTIONS)
|
||||
const downloadPath = `${base}/files/${encodeURIComponent(id(receipt.fileId))}/download`
|
||||
const download = await receiptBinary(context, merchantToken, downloadPath)
|
||||
const exactBytes = download.http === 200 && /application\/octet-stream/i.test(download.contentType)
|
||||
&& new TextDecoder().decode(download.bytes) === receipt.content
|
||||
context.addStep(MODULE, '客户下载自身签收文件字节等于本轮上传内容', download, exactBytes, trace(download), OPTIONS)
|
||||
verified &&= Boolean(bound && exactBytes)
|
||||
const otherReceipt = receiptFiles.find(other => other.deliveryNo !== receipt.deliveryNo)
|
||||
if (otherReceipt) {
|
||||
const wrongFile = await receiptBinary(context, merchantToken,
|
||||
`${base}/files/${encodeURIComponent(id(otherReceipt.fileId))}/download`)
|
||||
context.addStep(MODULE, '客户不得将另一派送单签收文件挂到本单读取', wrongFile,
|
||||
hiddenOrForbidden(wrongFile), trace(wrongFile), OPTIONS)
|
||||
verified &&= hiddenOrForbidden(wrongFile)
|
||||
}
|
||||
if (foreignToken) {
|
||||
const deniedDetail = await context.request('GET', base, undefined, foreignToken, {})
|
||||
context.addStep(MODULE, '另一客户不得读取本企业派送签收资料', deniedDetail,
|
||||
hiddenOrForbidden(deniedDetail), trace(deniedDetail), OPTIONS)
|
||||
const deniedFile = await receiptBinary(context, foreignToken, downloadPath)
|
||||
context.addStep(MODULE, '另一客户不得下载本企业派送签收文件', deniedFile,
|
||||
hiddenOrForbidden(deniedFile), trace(deniedFile), OPTIONS)
|
||||
verified &&= hiddenOrForbidden(deniedDetail) && hiddenOrForbidden(deniedFile)
|
||||
}
|
||||
}
|
||||
if (!foreignToken) {
|
||||
blocker(context, '客户签收文件跨企业隔离', '本轮缺少独立另一客户会话,自己的下载成功不能替代跨客户拒绝')
|
||||
return false
|
||||
}
|
||||
return verified
|
||||
}
|
||||
|
||||
export function relabelOutboundMarksMatch(detail, relabel) {
|
||||
const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '')
|
||||
.split(',').map(mark => mark.trim()).filter(Boolean))
|
||||
@@ -349,16 +676,40 @@ export async function run(context) {
|
||||
|
||||
const access = await resolvePickerAccess(context)
|
||||
const picker = access ? await createPicker(context, 5, warehouseId, access) : null
|
||||
const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))]
|
||||
if (dispatchInboundCodes.length === 0) {
|
||||
blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码')
|
||||
await disablePicker(context, picker, 5)
|
||||
return
|
||||
}
|
||||
const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存')
|
||||
// Dispatch scans retain the immutable DS source; the real OB is related through its dispatch and PDA picks.
|
||||
const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水')
|
||||
try {
|
||||
if (!nonBlank(picker?.token)) {
|
||||
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
|
||||
return
|
||||
}
|
||||
if (!await pickAll(context, picker.token, dispatchNo)) return
|
||||
if (!stockBeforePick || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return
|
||||
const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存')
|
||||
const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水')
|
||||
const shippedRows = ledgerAfterPick?.filter(row => !ledgerBeforePick.some(before => before.id === row.id))
|
||||
const expectedPackages = Number(transferred.data.packageCount)
|
||||
context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null,
|
||||
stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages
|
||||
&& expectedPackages > 0 && shippedRows?.length > 0
|
||||
&& -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages
|
||||
&& shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages),
|
||||
`before=${quantityTotal(stockBeforePick)}, after=${quantityTotal(stockAfterPick)}, expected=${expectedPackages}, SHIPRows=${shippedRows?.length ?? 'missing'}`, OPTIONS)
|
||||
const ready = await pda(context, picker.token, 'GET', '发车前回查服务端canDepart门禁',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
context.assert(MODULE, '发车前服务端canDepart明确为true', ready.response,
|
||||
ready.data?.canDepart === true && ready.data?.status === 'READY_TO_DEPART', trace(ready.response), OPTIONS)
|
||||
if (!success(ready.response) || ready.data?.canDepart !== true) return
|
||||
const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` }
|
||||
const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
|
||||
context.assert(MODULE, '发车推进排车DEPARTED并扣减实物库存一次', departed.response,
|
||||
context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response,
|
||||
departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0,
|
||||
trace(departed.response), OPTIONS)
|
||||
if (!success(departed.response)) return
|
||||
@@ -368,6 +719,16 @@ export async function run(context) {
|
||||
departReplay.data?.replayed === true
|
||||
&& Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages),
|
||||
trace(departReplay.response), OPTIONS)
|
||||
const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存')
|
||||
const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水')
|
||||
context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response,
|
||||
sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS)
|
||||
context.createdResources.phase5OverseasFulfillment.inventoryProof = {
|
||||
inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick),
|
||||
afterPackages: quantityTotal(stockAfterDepart), expectedPackages,
|
||||
shipmentLedgerIds: (shippedRows || []).map(row => row.id),
|
||||
canDepart: ready.data?.canDepart === true,
|
||||
}
|
||||
} finally {
|
||||
await disablePicker(context, picker, 5)
|
||||
if (picker) picker.token = null
|
||||
@@ -453,6 +814,17 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result,
|
||||
success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED',
|
||||
`orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS)
|
||||
context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId
|
||||
const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态',
|
||||
`/api/merchant/v1/orders/${encodeURIComponent(sourceOrderId)}`)
|
||||
context.assert(MODULE, '客户本企业订单最终状态为COMPLETED', customerCompletedOrder.response,
|
||||
customerCompletedOrder.data?.summary?.mainStatus === 'COMPLETED'
|
||||
&& id(customerCompletedOrder.data?.summary?.orderId) === sourceOrderId,
|
||||
trace(customerCompletedOrder.response), OPTIONS)
|
||||
const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken
|
||||
|| await createSignedDocumentIsolationCustomer(context)
|
||||
await verifyCustomerSignedDocuments(context, merchantToken,
|
||||
context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken)
|
||||
|
||||
const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果',
|
||||
query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), {
|
||||
@@ -531,5 +903,9 @@ export async function run(context) {
|
||||
exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo)
|
||||
&& exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo),
|
||||
`records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS)
|
||||
context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT'
|
||||
const returnReceipt = await completeReturnedException(context, { customerId, warehouseId,
|
||||
exceptionNo: returnExceptionNo, outboundNo })
|
||||
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
|
||||
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
|
||||
? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user