1232 lines
89 KiB
JavaScript
1232 lines
89 KiB
JavaScript
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'
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
|
const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'
|
|
const LASTLEG_BASE = '/api/platform/v1/last-leg'
|
|
const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'
|
|
const MERCHANT_DELIVERY_BASE = '/api/merchant/v1/last-leg/delivery-orders'
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
|
|
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
|
function code(response) { return String(response?.body?.code ?? '') }
|
|
function conflict(response) {
|
|
return response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))
|
|
}
|
|
function trace(response) {
|
|
return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
|
}
|
|
function query(path, values) {
|
|
const params = new URLSearchParams()
|
|
for (const [name, value] of Object.entries(values || {})) {
|
|
if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value))
|
|
}
|
|
return params.size ? `${path}?${params}` : path
|
|
}
|
|
function key(context, action, suffix = '') {
|
|
return `${context.marker}-P5-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 200)
|
|
}
|
|
function blocker(context, name, reason, response = null) {
|
|
context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true })
|
|
context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS)
|
|
}
|
|
async function platform(context, method, name, path, body = undefined) {
|
|
const response = await context.request(method, path, body)
|
|
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
|
return { response, data }
|
|
}
|
|
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 merchant(context, token, method, name, path, body = undefined) {
|
|
const response = await context.request(method, path, body, token, {})
|
|
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
|
return { response, data }
|
|
}
|
|
|
|
function transferDeliveries(data) {
|
|
return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : []
|
|
}
|
|
|
|
function deliveryNumbers(data) {
|
|
return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort()
|
|
}
|
|
|
|
export function dispatchDestinationConservation(detail, fixture, deliveries = null) {
|
|
const codes = values => values.map(value => id(value).trim().toUpperCase())
|
|
const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank)
|
|
&& right.every(nonBlank) && JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort())
|
|
const items = detail?.items
|
|
if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.planPalletIds)
|
|
|| !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.route)) return false
|
|
const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id)
|
|
if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length
|
|
|| new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false
|
|
const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode))
|
|
const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(','))
|
|
if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false
|
|
if (deliveries == null) return true
|
|
if (!Array.isArray(deliveries) || !deliveries.length) return false
|
|
const delivered=codes(deliveries.map(row => row.destinationWarehouseCode))
|
|
return delivered.length === new Set(delivered).size && sameSet(delivered,actual)
|
|
}
|
|
|
|
async function poll(action, predicate, attempts = 45, intervalMs = 1_000) {
|
|
let result = null
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
result = await action()
|
|
if (predicate(result)) return { result, attempts: attempt }
|
|
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
}
|
|
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
|
|
}
|
|
|
|
const POSITION_BUCKETS = ['totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
|
|
const BALANCE_BUCKETS = [...POSITION_BUCKETS, 'inTransitPackages', 'procurementPackages']
|
|
const INVENTORY_IDENTITIES = ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId',
|
|
'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', 'palletId', 'palletNo',
|
|
'batchNo', 'warehouseLocationId', 'warehouseLocationCode']
|
|
|
|
export function inventoryQuantitySnapshot(rows) {
|
|
return (rows || []).map(row => Object.fromEntries([
|
|
...INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, id(row[field])]),
|
|
...BALANCE_BUCKETS.filter(field => row[field] !== undefined).map(field => [field, Number(row[field])]),
|
|
])).sort((a, b) => a.id.localeCompare(b.id))
|
|
}
|
|
|
|
const balanceDimension = row => JSON.stringify([id(row.customerId), id(row.overseasWarehouseId),
|
|
id(row.destinationWarehouseId), id(row.destinationWarehouseCode), id(row.skuCode)])
|
|
const validBuckets = (row, fields) => fields.every(field => row[field] !== null && row[field] !== undefined
|
|
&& row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0)
|
|
|
|
export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name, sourceBalanceIds = []) {
|
|
const codes = new Set(inboundCodes)
|
|
const sourceIds = [...new Set(sourceBalanceIds.map(id))].sort()
|
|
if (!sourceIds.length || sourceIds.some(value => !/^[1-9][0-9]*$/.test(value))) {
|
|
blocker(context, name, '需要本轮正式PDA Task.positions冻结的全部真实balance IDs;分组代表ID不能冒充完整来源')
|
|
return null
|
|
}
|
|
const inventoryEvidence = context.createdResources?.phase5OverseasFulfillment
|
|
if (inventoryEvidence) {
|
|
inventoryEvidence.inventoryReadEvidence ||= {}
|
|
inventoryEvidence.inventoryReadEvidence[name] = { customerId, warehouseId, inboundCodes: [...codes],
|
|
sourceBalanceIds: sourceIds, complete: false, globalGroups: null, sourceBalances: [] }
|
|
}
|
|
const readEvidence = inventoryEvidence?.inventoryReadEvidence?.[name]
|
|
const groups = await allPages(context, `${LASTLEG_BASE}/inventory/balances`,
|
|
{ customerId, overseasWarehouseId: warehouseId }, `${name}-客户仓全部分组余额`)
|
|
if (!groups) return null
|
|
const groupValid = groups.every(row => id(row.customerId) === customerId
|
|
&& id(row.overseasWarehouseId) === warehouseId && validBuckets(row, BALANCE_BUCKETS))
|
|
&& new Set(groups.map(balanceDimension)).size === groups.length
|
|
context.assert(MODULE, `${name}完整分组客户仓数量及维度唯一`, null, groupValid, `groups=${groups.length}`, OPTIONS)
|
|
if (!groupValid) return null
|
|
if (readEvidence) readEvidence.globalGroups = inventoryQuantitySnapshot(groups)
|
|
context.context ||= {}
|
|
context.context.phase5InventorySources ||= {}
|
|
const frozen = context.context.phase5InventorySources
|
|
const snapshot = []
|
|
for (const balanceId of sourceIds) {
|
|
const sourceKey = `${customerId}:${warehouseId}:${balanceId}`
|
|
const detailResponse = await context.request('GET', query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}`, { customerId }))
|
|
const missingZero = detailResponse.http === 404 || (detailResponse.http === 200 && code(detailResponse) === 'A0404')
|
|
if (!success(detailResponse) && !missingZero) {
|
|
context.apiSuccess(MODULE, `${name}读取冻结真实余额`, detailResponse, trace(detailResponse), OPTIONS)
|
|
return null
|
|
}
|
|
const current = await platform(context, 'GET', `${name}读取冻结余额实际全部位置`,
|
|
query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}/positions`, { customerId }))
|
|
const positions = current.data
|
|
const valid = success(current.response) && Array.isArray(positions)
|
|
&& new Set(positions.map(row => id(row.id))).size === positions.length
|
|
&& positions.every(row => nonBlank(id(row.id)) && id(row.balanceId) === balanceId
|
|
&& id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId
|
|
&& nonBlank(row.inboundCode) && validBuckets(row, POSITION_BUCKETS))
|
|
context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid,
|
|
`balanceId=${balanceId}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS)
|
|
if (!valid) return null
|
|
let balance = detailResponse.body?.data
|
|
if (missingZero) {
|
|
const prior = frozen[sourceKey]
|
|
const currentGroup = prior && groups.find(row => balanceDimension(row) === balanceDimension(prior.balance))
|
|
const zeroProof = prior && positions.every(row => POSITION_BUCKETS.every(field => Number(row[field]) === 0))
|
|
&& ['inTransitPackages', 'procurementPackages'].every(field => Number(prior.balance[field]) === 0)
|
|
&& BALANCE_BUCKETS.every(field => Number(currentGroup?.[field] || 0)
|
|
<= Number(prior.group?.[field] || 0) - Number(prior.balance[field]))
|
|
context.assert(MODULE, `${name}零余额排除由原ID位置及完整分组共同证实`, detailResponse, Boolean(zeroProof),
|
|
`balanceId=${balanceId}, retainedSource=${Boolean(prior)}, zeroPositions=${positions.length}`, OPTIONS)
|
|
if (!zeroProof) return null
|
|
balance = { ...prior.balance, ...Object.fromEntries(BALANCE_BUCKETS.map(field => [field, 0])) }
|
|
} else {
|
|
const owned = id(balance?.id) === balanceId && id(balance?.customerId) === customerId
|
|
&& id(balance?.overseasWarehouseId) === warehouseId && validBuckets(balance, BALANCE_BUCKETS)
|
|
context.assert(MODULE, `${name}真实余额客户仓及完整数量有效`, detailResponse, owned, `balanceId=${balanceId}`, OPTIONS)
|
|
if (!owned) return null
|
|
const group = groups.find(row => balanceDimension(row) === balanceDimension(balance))
|
|
const included = Boolean(group) && BALANCE_BUCKETS.every(field => Number(group[field]) >= Number(balance[field]))
|
|
context.assert(MODULE, `${name}真实非零余额包含于独立完整分组`, detailResponse, included, `balanceId=${balanceId}`, OPTIONS)
|
|
if (!included) return null
|
|
frozen[sourceKey] = { balance: inventoryQuantitySnapshot([balance])[0], group: inventoryQuantitySnapshot([group])[0] }
|
|
}
|
|
if (readEvidence) readEvidence.sourceBalances.push({ balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions) })
|
|
const reconciled = POSITION_BUCKETS.every(field => positions.reduce((sum, row) => sum + Number(row[field]), 0) === Number(balance[field]))
|
|
context.assert(MODULE, `${name}全部位置独立核平原余额`, current.response, reconciled,
|
|
`balanceId=${balanceId}, balanceTotal=${balance.totalPackages}`, OPTIONS)
|
|
if (!reconciled) return null
|
|
const exact = positions.filter(row => codes.has(row.inboundCode))
|
|
snapshot.push({ id: balanceId, customerId, overseasWarehouseId: warehouseId,
|
|
inboundCodes: [...new Set(exact.map(row => row.inboundCode))].sort(),
|
|
...Object.fromEntries(POSITION_BUCKETS.map(field => [field, exact.reduce((sum, row) => sum + Number(row[field]), 0)])),
|
|
balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions),
|
|
matchingPositionIds: exact.map(row => id(row.id)).sort(), globalGroups: inventoryQuantitySnapshot(groups) })
|
|
}
|
|
const jointlyIncluded = groups.every(group => BALANCE_BUCKETS.every(field => snapshot
|
|
.filter(row => balanceDimension(row.balance) === balanceDimension(group))
|
|
.reduce((sum, row) => sum + Number(row.balance[field]), 0) <= Number(group[field])))
|
|
context.assert(MODULE, `${name}固定原始余额合计不超过独立分组`, null, jointlyIncluded,
|
|
`rawBalances=${snapshot.length}, groups=${groups.length}`, OPTIONS)
|
|
if (readEvidence) readEvidence.complete = jointlyIncluded
|
|
return jointlyIncluded ? snapshot : null
|
|
}
|
|
|
|
export function dispatchInventorySources(task, dispatchNo, warehouseId) {
|
|
if (task?.dispatchNo !== dispatchNo || id(task.warehouseId) !== warehouseId
|
|
|| !Array.isArray(task.items) || !task.items.length) return null
|
|
const positions = task.items.flatMap(item => Array.isArray(item.positions) ? item.positions : [])
|
|
if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.id)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null
|
|
const references = new Map()
|
|
for (const row of positions) {
|
|
const previous = references.get(id(row.id))
|
|
if (previous && previous !== id(row.balanceId)) return null
|
|
references.set(id(row.id), id(row.balanceId))
|
|
}
|
|
return { sourceBalanceIds: [...new Set(positions.map(row => id(row.balanceId)))].sort(),
|
|
sourcePositionIds: [...references.keys()].sort(),
|
|
sourcePositionBalances: [...references].map(([positionId, balanceId]) => ({ positionId, balanceId }))
|
|
.sort((left, right) => left.positionId.localeCompare(right.positionId)) }
|
|
}
|
|
|
|
export function inventorySourcesPresent(snapshot, references) {
|
|
return Array.isArray(snapshot) && references.every(reference => snapshot
|
|
.find(row => row.id === reference.balanceId)?.positions.some(position => position.id === reference.positionId
|
|
&& position.balanceId === reference.balanceId))
|
|
}
|
|
|
|
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)
|
|
const quantities = rows.map(row => ({ id: id(row.id), customerId: id(row.customerId),
|
|
overseasWarehouseId: id(row.overseasWarehouseId), inboundOrderId: id(row.inboundOrderId),
|
|
inboundOrderNo: id(row.inboundOrderNo), inboundCode: id(row.inboundCode), palletNo: id(row.palletNo),
|
|
batchNo: id(row.batchNo), skuCode: id(row.skuCode), businessType: id(row.businessType),
|
|
businessOrderNo: id(row.businessOrderNo), outboundOrderNo: id(row.outboundOrderNo),
|
|
deltaPackages: Number(row.deltaPackages), beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) }))
|
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
const owned = context.createdResources?.phase5OverseasFulfillment
|
|
if (owned) {
|
|
owned.ledgerReadEvidence ||= {}
|
|
owned.ledgerReadEvidence[name] = { customerId, outboundNo, businessType, businessOrderNo: businessOrderNo || null,
|
|
complete: exact.length === rows.length, rows: quantities }
|
|
}
|
|
return exact.length === rows.length ? quantities : null
|
|
}
|
|
|
|
const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0)
|
|
const groupQuantityTotal = rows => (rows?.[0]?.globalGroups || []).reduce((sum, row) => sum + Number(row.totalPackages), 0)
|
|
const balanceQuantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.balance.totalPackages), 0)
|
|
const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right)
|
|
|
|
export function dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages,
|
|
sourcePositionBalances, stockBefore, stockAfter, ledgerBefore, ledgerAfter }) {
|
|
const positions = snapshot => (snapshot || []).flatMap(row => row.positions || [])
|
|
const referenceKeys = new Set(sourcePositionBalances.map(row => `${row.balanceId}:${row.positionId}`))
|
|
const selected = snapshot => positions(snapshot).filter(row => referenceKeys.has(`${row.balanceId}:${row.id}`))
|
|
const positionBefore = selected(stockBefore)
|
|
const positionAfter = selected(stockAfter)
|
|
const newLedger = ledgerAfter?.filter(row => !ledgerBefore?.some(before => before.id === row.id)) || []
|
|
const rowChecks = newLedger.map(row => ({ id: row.id,
|
|
identity: row.customerId === customerId && row.overseasWarehouseId === warehouseId
|
|
&& row.businessType === 'SHIP' && row.businessOrderNo === dispatchNo && row.outboundOrderNo === dispatchNo
|
|
&& positionBefore.some(position => position.palletNo === row.palletNo),
|
|
quantities: [row.beforePackages, row.deltaPackages, row.afterPackages].every(Number.isSafeInteger)
|
|
&& row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages }))
|
|
const identity = row => Object.fromEntries(INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, row[field]]))
|
|
const sourcesStable = positionBefore.every(row => {
|
|
const after = positionAfter.find(value => value.id === row.id && value.balanceId === row.balanceId)
|
|
return after && JSON.stringify(identity(row)) === JSON.stringify(identity(after))
|
|
})
|
|
const unselectedBefore = positions(stockBefore).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`))
|
|
const unselectedAfter = positions(stockAfter).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`))
|
|
const totals = { positionBefore: quantityTotal(positionBefore), positionAfter: quantityTotal(positionAfter),
|
|
rawBefore: balanceQuantityTotal(stockBefore), rawAfter: balanceQuantityTotal(stockAfter),
|
|
groupBefore: groupQuantityTotal(stockBefore), groupAfter: groupQuantityTotal(stockAfter),
|
|
ledgerShipped: -newLedger.reduce((sum, row) => sum + row.deltaPackages, 0), expectedPackages }
|
|
const checks = {
|
|
fixedSourcesPresent: inventorySourcesPresent(stockBefore, sourcePositionBalances)
|
|
&& inventorySourcesPresent(stockAfter, sourcePositionBalances),
|
|
sourceIdentitiesStable: sourcesStable,
|
|
unselectedPositionsUnchanged: sameSnapshot(inventoryQuantitySnapshot(unselectedBefore), inventoryQuantitySnapshot(unselectedAfter)),
|
|
positionConservation: expectedPackages > 0 && totals.positionBefore - totals.positionAfter === expectedPackages,
|
|
rawConservation: stockBefore !== null && stockAfter !== null && totals.rawBefore - totals.rawAfter === expectedPackages,
|
|
groupConservation: stockBefore !== null && stockAfter !== null && totals.groupBefore - totals.groupAfter === expectedPackages,
|
|
ledgerSourceIdentities: ledgerBefore !== null && ledgerAfter !== null && newLedger.length > 0
|
|
&& new Set(newLedger.map(row => row.id)).size === newLedger.length && rowChecks.every(row => row.identity),
|
|
ledgerQuantities: newLedger.length > 0 && totals.ledgerShipped === expectedPackages && rowChecks.every(row => row.quantities),
|
|
}
|
|
const proof = { sourcePositionBalances, totals, checks, ledgerRowChecks: rowChecks,
|
|
snapshots: { beforePick: stockBefore, afterPick: stockAfter }, ledgers: { beforePick: ledgerBefore, afterPick: ledgerAfter } }
|
|
if (context.createdResources?.phase5OverseasFulfillment) {
|
|
Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, proof)
|
|
}
|
|
for (const [name, passed] of Object.entries(checks)) {
|
|
context.assert(MODULE, `本排车库存独立核对-${name}`, null, passed, JSON.stringify(totals), OPTIONS)
|
|
}
|
|
const passed = Object.values(checks).every(Boolean)
|
|
context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, passed, JSON.stringify(totals), OPTIONS)
|
|
return { passed, proof, newLedger }
|
|
}
|
|
|
|
// Completion follows the formal root and every real child; a signed shipment alone is not the aggregate.
|
|
export async function verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken,
|
|
pollAttempts = 45, pollIntervalMs = 1_000 }) {
|
|
const rawPath = orderId => query(`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { tenantId: customerId })
|
|
const source = await platform(context, 'GET', '读取签收来源原始订单履约归属', rawPath(sourceOrderId))
|
|
const sourceOrder = source.data?.order
|
|
const rootOrderId = sourceOrder?.orderRole === 'MASTER' ? id(sourceOrder.id) : id(sourceOrder?.rootOrderId)
|
|
const sourceOwned = success(source.response) && id(sourceOrder?.id) === sourceOrderId
|
|
&& id(sourceOrder?.tenantId) === customerId && id(sourceOrder?.customerId) === customerId
|
|
&& /^[1-9][0-9]*$/.test(rootOrderId)
|
|
&& (sourceOrder.orderRole === 'MASTER' || (sourceOrder.orderRole === 'CHILD' && id(sourceOrder.parentOrderId) === rootOrderId))
|
|
context.assert(MODULE, '签收来源订单属于本企业及唯一正式履约根', source.response, sourceOwned, `sourceOrderId=${sourceOrderId}, rootOrderId=${rootOrderId}`, OPTIONS)
|
|
if (!sourceOwned) return null
|
|
let root = rootOrderId === sourceOrderId ? source : await platform(context, 'GET', '读取原始履约根订单', rawPath(rootOrderId))
|
|
const treeResult = await platform(context, 'GET', '读取签收后的完整正式履约树',
|
|
query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}/fulfillment-tree`, { tenantId: customerId }))
|
|
const tree = treeResult.data
|
|
const rows = Array.isArray(tree?.children) ? tree.children : []
|
|
const treeOrderIds = new Set([rootOrderId, ...rows.map(row => id(row.orderId))])
|
|
const consumed = new Set(rows.filter(row => row.splitRole === 'LOADED').map(row => id(row.sourceOrderId)))
|
|
const loaded = rows.filter(row => row.splitRole === 'LOADED').reduce((sum, row) => sum + Number(row.packageCount), 0)
|
|
const remaining = rows.filter(row => row.splitRole === 'REMAINDER' && !consumed.has(id(row.orderId)))
|
|
.reduce((sum, row) => sum + Number(row.packageCount), 0)
|
|
const treeComplete = success(root.response) && success(treeResult.response)
|
|
&& id(root.data?.order?.id) === rootOrderId && root.data.order.orderRole === 'MASTER'
|
|
&& id(root.data.order.tenantId) === customerId && id(root.data.order.customerId) === customerId
|
|
&& Array.isArray(root.data.statusHistory)
|
|
&& id(tree?.parentOrderId) === rootOrderId && tree.parentOrderNo === root.data.order.orderNo
|
|
&& Array.isArray(tree.children)
|
|
&& ['originalPackageCount', 'cumulativeLoadedPackageCount', 'currentRemainderPackageCount'].every(field =>
|
|
tree[field] !== null && tree[field] !== undefined && Number.isSafeInteger(Number(tree[field])) && Number(tree[field]) >= 0)
|
|
&& loaded === Number(tree.cumulativeLoadedPackageCount) && remaining === Number(tree.currentRemainderPackageCount)
|
|
&& loaded + remaining === Number(tree.originalPackageCount)
|
|
&& rows.every(row => /^[1-9][0-9]*$/.test(id(row.orderId)) && /^[1-9][0-9]*$/.test(id(row.sourceOrderId))
|
|
&& treeOrderIds.has(id(row.sourceOrderId))
|
|
&& nonBlank(row.orderNo) && ['LOADED', 'REMAINDER'].includes(row.splitRole)
|
|
&& (row.splitRole === 'LOADED' ? id(row.orderId) === id(row.sourceOrderId) : id(row.orderId) !== id(row.sourceOrderId))
|
|
&& Number.isSafeInteger(Number(row.packageCount)) && Number(row.packageCount) > 0)
|
|
&& (sourceOrderId === rootOrderId || rows.some(row => id(row.orderId) === sourceOrderId))
|
|
context.assert(MODULE, '履约根及全部分片身份和树数量完整守恒', treeResult.response, treeComplete,
|
|
`rootOrderId=${rootOrderId}, loaded=${loaded}, remaining=${remaining}, original=${tree?.originalPackageCount ?? 'missing'}`, OPTIONS)
|
|
if (!treeComplete) return null
|
|
const children = []
|
|
const statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED']
|
|
for (const childId of [...new Set(rows.map(row => id(row.orderId)).filter(value => value !== rootOrderId))]) {
|
|
const current = await platform(context, 'GET', '逐项读取真实履约子单完成事实', rawPath(childId))
|
|
const child = current.data?.order
|
|
const owned = success(current.response) && id(child?.id) === childId && id(child?.tenantId) === customerId
|
|
&& id(child?.customerId) === customerId && child?.orderRole === 'CHILD'
|
|
&& id(child?.rootOrderId) === rootOrderId && id(child?.parentOrderId) === rootOrderId
|
|
&& rows.filter(row => id(row.orderId) === childId).every(row => row.orderNo === child.orderNo)
|
|
&& statuses.includes(child?.status)
|
|
context.assert(MODULE, '真实履约子单存在且客户根身份和状态有效', current.response, owned, `childOrderId=${childId}`, OPTIONS)
|
|
if (!owned) return null
|
|
children.push({ orderId: childId, orderNo: child.orderNo, status: child.status, rootOrderId })
|
|
}
|
|
const pendingChildren = children.filter(child => !['COMPLETED', 'CANCELLED'].includes(child.status))
|
|
const rootDelivered = root.data.statusHistory?.some(row => row.action === 'OVERSEAS_DELIVERED') === true
|
|
const expectedCompleted = rootDelivered && pendingChildren.length === 0
|
|
const owned = context.createdResources.phase5OverseasFulfillment
|
|
if (owned) owned.orderCompletionProof = { sourceOrderId, rootOrderId,
|
|
tree: { parentOrderId: rootOrderId, parentOrderNo: tree.parentOrderNo, originalPackageCount: Number(tree.originalPackageCount),
|
|
cumulativeLoadedPackageCount: loaded, currentRemainderPackageCount: remaining,
|
|
children: rows.map(row => ({ orderId: id(row.orderId), orderNo: row.orderNo, sourceOrderId: id(row.sourceOrderId),
|
|
splitRole: row.splitRole, packageCount: Number(row.packageCount) })) },
|
|
children, rootDelivered, expectedCompleted, pendingChildOrderIds: pendingChildren.map(child => child.orderId) }
|
|
const domestic = await poll(() => context.request('GET',
|
|
query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}`, { tenantId: customerId })),
|
|
response => success(response) && id(response.body?.data?.summary?.orderId) === rootOrderId
|
|
&& (expectedCompleted ? response.body.data.summary.mainStatus === 'COMPLETED'
|
|
: rootDelivered ? response.body.data.summary.mainStatus === 'DELIVERED'
|
|
: nonBlank(response.body.data.summary.mainStatus) && response.body.data.summary.mainStatus !== 'COMPLETED'), pollAttempts, pollIntervalMs)
|
|
if (expectedCompleted) root = await platform(context, 'GET', '全部有效分片结束后回查原始履约根', rawPath(rootOrderId))
|
|
const rawPassed = success(root.response) && (expectedCompleted
|
|
? root.data?.order?.status === 'COMPLETED' && root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED')
|
|
: statuses.includes(root.data?.order?.status) && root.data.order.status !== 'COMPLETED'
|
|
&& !root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED'))
|
|
const platformPassed = success(domestic.result) && id(domestic.result?.body?.data?.summary?.orderId) === rootOrderId
|
|
&& (expectedCompleted ? domestic.result.body.data.summary.mainStatus === 'COMPLETED'
|
|
: rootDelivered ? domestic.result.body.data.summary.mainStatus === 'DELIVERED'
|
|
: nonBlank(domestic.result.body.data.summary.mainStatus) && domestic.result.body.data.summary.mainStatus !== 'COMPLETED')
|
|
context.assert(MODULE, expectedCompleted ? '全部有效子单签收后原始履约根COMPLETED' : '有效余量或根票未签收时原始履约根不得提前COMPLETED',
|
|
root.response, rawPassed, `rootOrderId=${rootOrderId}, pendingChildren=${pendingChildren.map(row => row.orderNo).join(',')}`, OPTIONS)
|
|
context.addStep(MODULE, expectedCompleted ? '同订单全部有效履约分片签收后统一订单进入COMPLETED' : '本票签收但有效余量未完时统一订单保留未完成状态',
|
|
domestic.result, platformPassed, `rootOrderId=${rootOrderId}, pollRequests=${domestic.attempts}, mainStatus=${domestic.result?.body?.data?.summary?.mainStatus || 'missing'}`, OPTIONS)
|
|
const customer = await merchant(context, merchantToken, 'GET', '客户读取本企业履约根真实完成状态', `/api/merchant/v1/orders/${encodeURIComponent(rootOrderId)}`)
|
|
const customerPassed = success(customer.response) && id(customer.data?.summary?.orderId) === rootOrderId
|
|
&& customer.data?.summary?.mainStatus === domestic.result?.body?.data?.summary?.mainStatus && platformPassed
|
|
context.assert(MODULE, expectedCompleted ? '客户本企业全部履约完成后订单最终状态为COMPLETED' : '客户本企业仍有有效未完成履约时不得展示COMPLETED',
|
|
customer.response, customerPassed, `rootOrderId=${rootOrderId}, mainStatus=${customer.data?.summary?.mainStatus || 'missing'}`, OPTIONS)
|
|
if (owned) Object.assign(owned.orderCompletionProof, { rawRootStatus: root.data?.order?.status,
|
|
platformMainStatus: domestic.result?.body?.data?.summary?.mainStatus, customerMainStatus: customer.data?.summary?.mainStatus,
|
|
rawPassed: Boolean(rawPassed), platformPassed, customerPassed })
|
|
return { rootOrderId, expectedCompleted, pendingChildren, passed: Boolean(rawPassed && platformPassed && customerPassed) }
|
|
}
|
|
|
|
// 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 sourceBalanceIds = owned.inventoryProof?.sourceBalanceIds || []
|
|
const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存', sourceBalanceIds)
|
|
const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo)
|
|
if (!stockBefore || !ledgerBefore) return null
|
|
if (!stockBefore.some(balance => balance.positions.some(position => position.id === id(source.inventoryPositionId)))) {
|
|
blocker(context, '退回原位置冻结归属', '原returnSource位置必须存在于正式排车冻结真实余额中')
|
|
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], '退回实收失败后库存', sourceBalanceIds)
|
|
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], '退回实收后库存', sourceBalanceIds)
|
|
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
|
|
&& balanceQuantityTotal(stockAfter) - balanceQuantityTotal(stockBefore) === 1
|
|
&& groupQuantityTotal(stockAfter) - groupQuantityTotal(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], '退回重放后库存', sourceBalanceIds)
|
|
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 })
|
|
const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath)
|
|
const version = Number(before.data?.version)
|
|
context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response,
|
|
Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS)
|
|
if (!success(before.response) || !Number.isInteger(version) || version < 0) return null
|
|
|
|
const uploadResponse = await context.requestMultipart('POST',
|
|
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), {
|
|
name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain',
|
|
content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`),
|
|
})
|
|
const uploaded = context.apiSuccess(MODULE, `上传第${index + 1}张派送单签收文件`, uploadResponse,
|
|
trace(uploadResponse), OPTIONS)
|
|
const fileId = id(uploaded?.fileId)
|
|
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,
|
|
fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)),
|
|
})).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
|
|
const saveBody = {
|
|
expectedVersion: version, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)),
|
|
bolNo: before.data?.bolNo || null,
|
|
documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }],
|
|
}
|
|
const saved = await platform(context, 'PUT', `保存第${index + 1}张派送单签收资料并自动完成`, documentsPath, saveBody)
|
|
context.assert(MODULE, `第${index + 1}张派送单签收资料保存后已上传`, saved.response,
|
|
saved.data?.categories?.some((category) => category?.category === 'RECEIPT'
|
|
&& category?.status === 'UPLOADED' && Number(category?.fileCount) === 1), trace(saved.response), OPTIONS)
|
|
if (!success(saved.response)) return null
|
|
|
|
const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody)
|
|
context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response,
|
|
savedReplay.data?.replayed === true && Number(savedReplay.data?.version) === Number(saved.data?.version),
|
|
trace(savedReplay.response), OPTIONS)
|
|
const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`,
|
|
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId }))
|
|
context.assert(MODULE, `第${index + 1}张派送单签收保存即进入DELIVERED`, completed.response,
|
|
completed.data?.order?.status === 'DELIVERED'
|
|
&& completed.data?.operations?.some((operation) => operation?.operationNode === 'RECEIPT_AUTO_COMPLETE'),
|
|
trace(completed.response), OPTIONS)
|
|
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))
|
|
const expected = relabel?.relabelCartonMarks || []
|
|
return expected.length > 0 && marks.length === expected.length
|
|
&& new Set(marks).size === marks.length && expected.every(mark => marks.includes(mark))
|
|
&& (relabel.sourceCartonMarks || []).every(mark => !marks.includes(mark))
|
|
&& Number(detail?.outbound?.packageCount) === expected.length
|
|
}
|
|
|
|
async function verifyRelabelDispatchOutbound(context, relabel, transferBody) {
|
|
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) {
|
|
blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据')
|
|
return
|
|
}
|
|
const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照',
|
|
`${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`,
|
|
{ ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') })
|
|
const outboundNo = transferred.data?.outboundNo
|
|
if (!success(transferred.response) || !nonBlank(outboundNo)) return
|
|
const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛',
|
|
query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 }))
|
|
context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response,
|
|
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
|
|
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
|
|
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
|
|
Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) })
|
|
}
|
|
|
|
async function loginMerchant(context, customerId) {
|
|
const merchantFixture = context.context?.merchantQualificationFixture
|
|
const credentials = nonBlank(merchantFixture?.username) && nonBlank(merchantFixture?.password)
|
|
? { username: merchantFixture.username, password: merchantFixture.password }
|
|
: { username: context.options?.merchantUsername, password: context.options?.merchantPassword }
|
|
if (!nonBlank(credentials.username) || !nonBlank(credentials.password)) {
|
|
blocker(context, '取得本轮客户工作台账号', '既无本轮动态客户凭据,也未配置开发验收客户凭据')
|
|
return null
|
|
}
|
|
const response = await context.request('POST', '/api/auth/login', {
|
|
loginId: credentials.username,
|
|
password: credentials.password,
|
|
}, '')
|
|
const auth = context.apiSuccess(MODULE, '本轮客户工作台账号登录', response, trace(response), OPTIONS)
|
|
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
|
|
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
|
|
if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.accessToken)) return null
|
|
const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken)
|
|
const profile = context.apiSuccess(MODULE, '回查客户工作台企业身份', profileResponse,
|
|
trace(profileResponse), OPTIONS)
|
|
context.assert(MODULE, '客户工作台企业与五期履约客户严格一致', profileResponse,
|
|
id(profile?.id) === customerId,
|
|
`merchantCustomer=${id(profile?.id) || 'missing'}, fulfillmentCustomer=${customerId}; ${trace(profileResponse)}`, OPTIONS)
|
|
return id(profile?.id) === customerId ? auth.accessToken : null
|
|
}
|
|
|
|
function fixture(context) {
|
|
const dispatch = context.createdResources?.phase4OverseasDispatch
|
|
const receiving = context.createdResources?.phase4OverseasPda
|
|
if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'PENDING_DEPARTURE'
|
|
|| !nonBlank(dispatch?.dispatchNo)) return null
|
|
if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED'
|
|
|| !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)) return null
|
|
return { dispatch, receiving }
|
|
}
|
|
|
|
export async function verifyDepartureReadiness(context, token, dispatchNo) {
|
|
const ready = await pda(context, token, 'GET', '发车前回查服务端canDepart门禁',
|
|
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
|
const dispatch = await platform(context, 'GET', '发车前独立回查实际排车状态',
|
|
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
|
const passed = success(ready.response) && success(dispatch.response)
|
|
&& ready.data?.dispatchNo === dispatchNo && ready.data?.status === 'COMPLETED'
|
|
&& ready.data?.canDepart === true && dispatch.data?.dispatchNo === dispatchNo
|
|
&& dispatch.data?.status === 'READY_TO_DEPART'
|
|
context.assert(MODULE, '发车前PDA任务完成且服务端门禁和实际排车待发均通过', ready.response,
|
|
passed, `taskStatus=${ready.data?.status || 'missing'}, canDepart=${ready.data?.canDepart}, dispatchStatus=${dispatch.data?.status || 'missing'}; ${trace(ready.response)}`, OPTIONS)
|
|
return passed ? ready : null
|
|
}
|
|
|
|
export async function pickAll(context, token, dispatchNo) {
|
|
let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务',
|
|
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
|
const items = Array.isArray(detail.data?.items) ? detail.data.items : []
|
|
if (!success(detail.response) || items.length === 0) {
|
|
blocker(context, 'PDA排车拣货任务', '本轮排车未生成可操作PDA托盘任务', detail.response)
|
|
return false
|
|
}
|
|
|
|
for (const [itemIndex, item] of items.entries()) {
|
|
let remaining = Number(item.scheduledPackages || 0) - Number(item.pickedPackages || 0)
|
|
const positions = Array.isArray(item.positions) ? item.positions : []
|
|
for (const [positionIndex, position] of positions.entries()) {
|
|
if (remaining <= 0) break
|
|
const available = Number(position.availablePackages || 0)
|
|
if (available <= 0) continue
|
|
const packages = Math.min(remaining, available)
|
|
const body = {
|
|
positionId: id(position.id), packages, productQuantity: packages,
|
|
idempotencyKey: key(context, 'DISPATCH-PICK', `${itemIndex}-${positionIndex}`),
|
|
remark: `${context.marker}-phase5-dispatch-pick`,
|
|
}
|
|
const picked = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}库存位${positionIndex + 1}`,
|
|
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body)
|
|
if (!success(picked.response)) return false
|
|
const replay = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}同键重放`,
|
|
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body)
|
|
context.assert(MODULE, `PDA拣货托盘${itemIndex + 1}重放数量稳定`, replay.response,
|
|
success(replay.response) && Number(replay.data?.pickedPackages) === Number(picked.data?.pickedPackages),
|
|
trace(replay.response), OPTIONS)
|
|
remaining -= packages
|
|
}
|
|
if (remaining !== 0) {
|
|
blocker(context, `PDA托盘${itemIndex + 1}足量库存`,
|
|
`scheduled=${item.scheduledPackages}, picked=${item.pickedPackages}, remaining=${remaining}`, detail.response)
|
|
return false
|
|
}
|
|
}
|
|
|
|
const completeBody = { idempotencyKey: key(context, 'DISPATCH-COMPLETE') }
|
|
const completed = await pda(context, token, 'POST', 'PDA确认排车拣货全部完成',
|
|
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody)
|
|
context.assert(MODULE, '排车进入READY_TO_DEPART且数量守恒', completed.response,
|
|
completed.data?.status === 'READY_TO_DEPART'
|
|
&& Number(completed.data?.plannedPackages) === Number(completed.data?.pickedPackages)
|
|
&& Number(completed.data?.remainingPackages) === 0,
|
|
trace(completed.response), OPTIONS)
|
|
if (!success(completed.response)) return false
|
|
const replay = await pda(context, token, 'POST', 'PDA完成排车拣货同键重放',
|
|
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody)
|
|
context.assert(MODULE, 'PDA完成拣货重放明确标记且结果稳定', replay.response,
|
|
replay.data?.replayed === true && replay.data?.status === 'READY_TO_DEPART', trace(replay.response), OPTIONS)
|
|
return success(replay.response)
|
|
}
|
|
|
|
export async function run(context) {
|
|
context.createdResources.phase5OverseasFulfillment = {
|
|
marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [],
|
|
visitId: null, manualExceptionNo: null, returnExceptionNo: null, finalStatus: null,
|
|
cleanup: 'marker-owned completed delivery, customer visit and return exception are retained as acceptance evidence',
|
|
}
|
|
const source = fixture(context)
|
|
if (!source) {
|
|
blocker(context, '取得本轮四期已收货排车前置',
|
|
`dispatchMarker=${context.createdResources?.phase4OverseasDispatch?.marker || 'missing'}, dispatchStatus=${context.createdResources?.phase4OverseasDispatch?.finalStatus || 'missing'}, receivingStatus=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`)
|
|
return
|
|
}
|
|
const dispatchNo = source.dispatch.dispatchNo
|
|
const customerId = id(source.receiving.customerId)
|
|
const warehouseId = id(source.receiving.overseasWarehouseId)
|
|
context.createdResources.phase5OverseasFulfillment.dispatchNo = dispatchNo
|
|
const merchantToken = await loginMerchant(context, customerId)
|
|
if (!merchantToken) return
|
|
|
|
const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
|
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
|
|
const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = {
|
|
frozenPlanPalletIds:source.dispatch.planPalletIds,
|
|
frozenDestinationWarehouseCodes:source.dispatch.destinationWarehouseCodes,
|
|
beforeEdit:beforeEdit.data,
|
|
}
|
|
context.assert(MODULE,'排车线路目的地集合与实际托盘及本轮冻结候选完全一致',beforeEdit.response,
|
|
dispatchDestinationConservation(beforeEdit.data,source.dispatch),trace(beforeEdit.response),OPTIONS)
|
|
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch)) return
|
|
const updateBody = {
|
|
vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64),
|
|
driverName: `五期验收司机-${context.marker.slice(-6)}`,
|
|
driverPhone: '19900000000', carrierName: '五期开发验收车队',
|
|
estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
}
|
|
const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路',
|
|
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody)
|
|
context.assert(MODULE, '排车编辑字段完整回显', updated.response,
|
|
updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate
|
|
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route,
|
|
trace(updated.response), OPTIONS)
|
|
if (!success(updated.response)) return
|
|
|
|
const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`)
|
|
context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol,
|
|
bol.http === 200 && /application\/pdf/i.test(bol.contentType || '')
|
|
&& new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS)
|
|
|
|
const transferBody = {
|
|
idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
|
|
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64),
|
|
carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
|
|
vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName,
|
|
driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate,
|
|
appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`,
|
|
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',
|
|
currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }],
|
|
}
|
|
await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody)
|
|
const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单',
|
|
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
|
const outboundNo = id(transferred.data?.outboundNo)
|
|
const deliveries = transferDeliveries(transferred.data)
|
|
const deliveryNos = deliveryNumbers(transferred.data)
|
|
const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId))
|
|
routeProof.deliveries = deliveries
|
|
context.assert(MODULE,'转派送目的地集合与实际排车线路及本轮冻结候选守恒',transferred.response,
|
|
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries),trace(transferred.response),OPTIONS)
|
|
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
|
|
const deliveryNo = deliveryNos[0] || ''
|
|
context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response,
|
|
nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length
|
|
&& new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank)
|
|
&& transferred.data?.outboundStatus === 'WAIT_OUTBOUND'
|
|
&& deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY')
|
|
&& deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount)
|
|
&& Number(transferred.data?.packageCount) > 0,
|
|
`outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS)
|
|
if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return
|
|
Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos })
|
|
const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单',
|
|
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody)
|
|
context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response,
|
|
adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo
|
|
&& JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS)
|
|
const lockedItem = updated.data?.items?.[0]
|
|
if (lockedItem?.id) {
|
|
const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`)
|
|
context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS)
|
|
const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
|
context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response,
|
|
Number(stable.data?.packageCount) === Number(updated.data?.packageCount)
|
|
&& JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS)
|
|
} else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID')
|
|
const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放',
|
|
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
|
context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response,
|
|
transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo
|
|
&& JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS)
|
|
const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {})
|
|
context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict,
|
|
success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo
|
|
&& transferConflict.body?.data?.replayed === true
|
|
&& JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS)
|
|
|
|
const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单',
|
|
query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo }))
|
|
const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : []
|
|
context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response,
|
|
deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number
|
|
&& id(value?.dispatchNo) === dispatchNo)),
|
|
`records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS)
|
|
const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情',
|
|
`${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`)
|
|
context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response,
|
|
id(merchantDetail.data?.order?.deliveryNo) === deliveryNo
|
|
&& id(merchantDetail.data?.order?.dispatchNo) === dispatchNo
|
|
&& nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode
|
|
|| merchantDetail.data?.order?.destinationWarehouseId))
|
|
&& Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0
|
|
&& Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations),
|
|
`skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`,
|
|
OPTIONS)
|
|
|
|
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
|
|
}
|
|
try {
|
|
if (!nonBlank(picker?.token)) {
|
|
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
|
|
return
|
|
}
|
|
const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源',
|
|
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
|
const sourceIds = dispatchInventorySources(sourceTask.data, dispatchNo, warehouseId)
|
|
context.assert(MODULE, '真实排车任务冻结全部库存位置及余额ID', sourceTask.response,
|
|
success(sourceTask.response) && Boolean(sourceIds), `dispatchNo=${dispatchNo}`, OPTIONS)
|
|
if (!sourceIds) return
|
|
const { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } = sourceIds
|
|
context.createdResources.phase5OverseasFulfillment.inventoryProof = { sourceBalanceIds, sourcePositionIds, sourcePositionBalances }
|
|
const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存', sourceBalanceIds)
|
|
const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水', 'SHIP', dispatchNo)
|
|
Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { snapshots: { beforePick: stockBeforePick }, ledgers: { beforePick: ledgerBeforePick } })
|
|
const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances)
|
|
context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS)
|
|
if (!sourcesBeforeValid || !ledgerBeforePick) return
|
|
const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo)
|
|
const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds)
|
|
const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo)
|
|
const expectedPackages = Number(transferred.data.packageCount)
|
|
const conservation = dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages,
|
|
sourcePositionBalances, stockBefore: stockBeforePick, stockAfter: stockAfterPick,
|
|
ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick })
|
|
const shippedRows = conservation.newLedger
|
|
if (!pickedSuccessfully) return
|
|
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo)
|
|
if (!ready) 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,
|
|
departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0,
|
|
trace(departed.response), OPTIONS)
|
|
if (!success(departed.response)) return
|
|
const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放',
|
|
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
|
|
context.assert(MODULE, 'PDA发车重放明确标记且发运数量稳定', departReplay.response,
|
|
departReplay.data?.replayed === true
|
|
&& Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages),
|
|
trace(departReplay.response), OPTIONS)
|
|
const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds)
|
|
const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水', 'SHIP', dispatchNo)
|
|
const inventoryProof = context.createdResources.phase5OverseasFulfillment.inventoryProof
|
|
inventoryProof.snapshots.afterDepart = stockAfterDepart
|
|
inventoryProof.ledgers.afterDepart = ledgerAfterDepart
|
|
context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response,
|
|
inventorySourcesPresent(stockAfterDepart, sourcePositionBalances)
|
|
&& sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS)
|
|
Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, {
|
|
sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: conservation.proof.totals.positionBefore,
|
|
afterPackages: quantityTotal((stockAfterDepart || []).flatMap(row => row.positions.filter(position => sourcePositionBalances.some(reference => reference.positionId === position.id && reference.balanceId === position.balanceId)))), expectedPackages,
|
|
shipmentLedgerIds: (shippedRows || []).map(row => row.id),
|
|
canDepart: ready.data?.canDepart === true,
|
|
})
|
|
} finally {
|
|
await disablePicker(context, picker, 5)
|
|
if (picker) picker.token = null
|
|
}
|
|
|
|
const outbound = await platform(context, 'GET', '回查发车后出库单状态',
|
|
query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 20 }))
|
|
context.assert(MODULE, '排车PDA发车同步出库单OUTBOUNDED', outbound.response,
|
|
outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED',
|
|
trace(outbound.response), OPTIONS)
|
|
|
|
for (const [index, delivery] of deliveries.entries()) {
|
|
const currentNo = id(delivery.deliveryNo)
|
|
const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`,
|
|
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId }))
|
|
const deliveryVersion = Number(beforeStatus.data?.order?.version)
|
|
context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response,
|
|
Number.isInteger(deliveryVersion) && deliveryVersion >= 0,
|
|
`version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS)
|
|
if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return
|
|
const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`,
|
|
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }),
|
|
{ status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` })
|
|
context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response,
|
|
delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS)
|
|
if (!success(delivering.response)) return
|
|
}
|
|
|
|
const completedDeliveries = []
|
|
for (const [index, delivery] of deliveries.entries()) {
|
|
const completed = await saveReceiptAndComplete(context, delivery, customerId, index)
|
|
if (!completed) return
|
|
completedDeliveries.push(completed)
|
|
}
|
|
context.assert(MODULE, '每个目的FBA仓派送单均由各自签收文件自动完成', null,
|
|
completedDeliveries.length === deliveries.length
|
|
&& completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'),
|
|
`deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS)
|
|
|
|
const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
|
|
context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete,
|
|
removedComplete?.http === 404 || removedComplete?.http === 405
|
|
|| (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))),
|
|
trace(removedComplete), OPTIONS)
|
|
|
|
const visits = await platform(context, 'GET', '按派送单回查签收自动生成的客户回访',
|
|
query(`${LASTLEG_BASE}/customer-visits`, { page: 1, size: 200, customerId, keyword: deliveryNo }))
|
|
const visitRow = Array.isArray(visits.data?.records)
|
|
? visits.data.records.find((value) => id(value?.deliveryNo) === deliveryNo) : null
|
|
const visitId = id(visitRow?.id)
|
|
context.assert(MODULE, '签收自动完成生成PENDING回访记录', visits.response,
|
|
/^\d+$/.test(visitId) && visitRow?.status === 'PENDING',
|
|
`visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(visits.response)}`, OPTIONS)
|
|
if (!visitId) return
|
|
context.createdResources.phase5OverseasFulfillment.visitId = visitId
|
|
|
|
const visitDetail = await platform(context, 'GET', '读取本轮客户回访详情',
|
|
query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }))
|
|
const inbound = Array.isArray(visitDetail.data?.inboundOrders)
|
|
? visitDetail.data.inboundOrders.find((value) => id(value?.inboundOrderId) && nonBlank(value?.lastMileOrderNo)) : null
|
|
context.assert(MODULE, '回访详情包含派送关联的真实入仓单与尾程单', visitDetail.response,
|
|
Boolean(inbound), `inboundOption=${Boolean(inbound)}; ${trace(visitDetail.response)}`, OPTIONS)
|
|
if (!inbound) return
|
|
|
|
const domesticInbound = await poll(async () => context.request('GET', query('/api/platform/inbounds', {
|
|
tenantId: customerId, inboundCode: inbound.inboundCode, page: 1, size: 20,
|
|
})), (response) => success(response) && Array.isArray(response.body?.data?.records)
|
|
&& response.body.data.records.some((value) => value?.inboundCode === inbound.inboundCode
|
|
&& value?.processStatus === 'DELIVERED'))
|
|
const inboundSummary = domesticInbound.result?.body?.data?.records?.find(
|
|
(value) => value?.inboundCode === inbound.inboundCode)
|
|
context.addStep(MODULE, '海外签收完成事件回推头程入仓单DELIVERED', domesticInbound.result,
|
|
success(domesticInbound.result) && inboundSummary?.processStatus === 'DELIVERED',
|
|
`inboundCode=${inbound.inboundCode}, pollRequests=${domesticInbound.attempts}, processStatus=${inboundSummary?.processStatus || 'missing'}; ${trace(domesticInbound.result)}`, OPTIONS)
|
|
const sourceOrderId = id(inboundSummary?.orderId)
|
|
if (!sourceOrderId) {
|
|
blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result)
|
|
return
|
|
}
|
|
context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId
|
|
await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken })
|
|
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 }), {
|
|
selectedInboundOrderId: id(inbound.inboundOrderId), visitResult: 'SATISFIED',
|
|
customerFeedback: `${context.marker}-customer-feedback`, remark: `${context.marker}-visit-completed`, attachments: [],
|
|
})
|
|
context.assert(MODULE, '客户回访完成并冻结所选业务入仓码', savedVisit.response,
|
|
savedVisit.data?.visit?.status === 'COMPLETED'
|
|
&& savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode
|
|
&& savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS)
|
|
|
|
const manualExceptionBody = {
|
|
customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo,
|
|
exceptionType: 'DAMAGED', reason: `${context.marker}-manual-exception`, packageCount: 1, skuCode: null,
|
|
}
|
|
let manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常',
|
|
`${LASTLEG_BASE}/exception-orders`, manualExceptionBody)
|
|
if (manualException.response.http === 0) {
|
|
manualException = await platform(context, 'POST', '人工异常连接失败后使用同载荷重试',
|
|
`${LASTLEG_BASE}/exception-orders`, manualExceptionBody)
|
|
}
|
|
const manualExceptionNo = id(manualException.data?.exceptionNo)
|
|
context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response,
|
|
nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS)
|
|
context.createdResources.phase5OverseasFulfillment.manualExceptionNo = manualExceptionNo || null
|
|
|
|
const returnOptions = await platform(context, 'GET', '查询派送退回可选入仓单',
|
|
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId }))
|
|
const returnInbound = Array.isArray(returnOptions.data)
|
|
? returnOptions.data.find((value) => Array.isArray(value?.returnSources)
|
|
&& value.returnSources.some((source) => id(source?.outboundPalletItemId)
|
|
&& id(source?.inventoryPositionId) && Number(source?.maxReturnPackages) > 0)) : null
|
|
context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response,
|
|
id(returnInbound?.inboundOrderId) && returnInbound?.deliveryNo === deliveryNo && returnInbound?.outboundNo === outboundNo,
|
|
`candidate=${Boolean(returnInbound)}; ${trace(returnOptions.response)}`, OPTIONS)
|
|
if (!returnInbound) return
|
|
const returnSource = Array.isArray(returnInbound.returnSources)
|
|
? returnInbound.returnSources.find((value) => id(value?.outboundPalletItemId)
|
|
&& id(value?.inventoryPositionId) && Number(value?.maxReturnPackages) > 0) : null
|
|
context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response,
|
|
Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS)
|
|
if (!returnSource) return
|
|
const returnBody = {
|
|
inboundOrderId: id(returnInbound.inboundOrderId), reason: `${context.marker}-delivery-return`,
|
|
returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128),
|
|
items: [{
|
|
outboundPalletItemId: id(returnSource.outboundPalletItemId),
|
|
inventoryPositionId: id(returnSource.inventoryPositionId),
|
|
packageCount: 1,
|
|
}],
|
|
idempotencyKey: key(context, 'DELIVERY-RETURN'),
|
|
}
|
|
const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常',
|
|
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody)
|
|
const returnExceptionNo = id(returned.data?.exceptionNo)
|
|
context.assert(MODULE, '派送退回生成独立PENDING异常单', returned.response,
|
|
nonBlank(returnExceptionNo) && returned.data?.status === 'PENDING'
|
|
&& returnExceptionNo !== manualExceptionNo, trace(returned.response), OPTIONS)
|
|
context.createdResources.phase5OverseasFulfillment.returnExceptionNo = returnExceptionNo || null
|
|
|
|
const returnReplay = await platform(context, 'POST', '派送退回同键同载荷重放',
|
|
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody)
|
|
context.assert(MODULE, '派送退回重放复用同一异常单', returnReplay.response,
|
|
returnReplay.data?.replayed === true && id(returnReplay.data?.exceptionNo) === returnExceptionNo,
|
|
trace(returnReplay.response), OPTIONS)
|
|
|
|
const delivery = await platform(context, 'GET', '回查派送退回终态',
|
|
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId }))
|
|
context.assert(MODULE, '派送退回后单据状态为RETURN_PENDING且保留出库关联', delivery.response,
|
|
delivery.data?.order?.status === 'RETURN_PENDING' && delivery.data?.order?.outboundNo === outboundNo,
|
|
trace(delivery.response), OPTIONS)
|
|
const exceptionPage = await platform(context, 'GET', '分页回查本轮两类异常',
|
|
query(`${LASTLEG_BASE}/exception-orders`, { page: 1, size: 20, customerId, keyword: context.marker }))
|
|
const exceptionRows = Array.isArray(exceptionPage.data?.records) ? exceptionPage.data.records : []
|
|
context.assert(MODULE, '异常列表同时包含人工异常与派送退回异常', exceptionPage.response,
|
|
exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo)
|
|
&& exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo),
|
|
`records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS)
|
|
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'
|
|
}
|