425 lines
28 KiB
JavaScript
425 lines
28 KiB
JavaScript
const MODULE = 'phase4-tail-order-controls'
|
|
const SCENARIO = 'phase4-tail-order-controls-lifecycle'
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
|
const ORDERS = '/api/platform/v1/last-leg/orders'
|
|
const PLANS = '/api/platform/v1/last-leg/inbound-plans'
|
|
const NODE_CODES = Object.freeze([
|
|
'DOMESTIC_CUSTOMS_COMPLETED', 'ESTIMATED_DEPARTURE', 'ACTUAL_DEPARTURE', 'ESTIMATED_ARRIVAL',
|
|
'OVERSEAS_CUSTOMS_COMPLETED', 'ACTUAL_ARRIVAL_WAITING_UNLOAD', 'PICKUP_APPOINTED', 'UNLOADED',
|
|
])
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) }
|
|
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
|
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') }
|
|
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
|
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
|
function localIso(value) { return value.toISOString().slice(0, 19) }
|
|
function trace(response) {
|
|
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
|
}
|
|
function query(path, values) {
|
|
const params = new URLSearchParams()
|
|
for (const [key, value] of Object.entries(values || {})) {
|
|
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value))
|
|
}
|
|
return params.size ? `${path}?${params}` : path
|
|
}
|
|
async function api(context, method, name, path, body, headers = {}) {
|
|
const response = await context.requestOverseas(method, path, body, headers, context.overseasToken)
|
|
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
|
return { response, data }
|
|
}
|
|
async function domesticApi(context, name, path) {
|
|
const response = await context.request('GET', path)
|
|
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
|
return { response, data }
|
|
}
|
|
function blocker(context, name, evidence, response = null) {
|
|
context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS)
|
|
context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true })
|
|
}
|
|
function targetProducer(context) {
|
|
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
|
if (matrix?.marker !== context.marker) return null
|
|
return matrix.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
|
|| matrix.byDestinationType?.FBA?.[0]
|
|
|| matrix.byDestinationType?.OVERSEAS?.[0]
|
|
|| null
|
|
}
|
|
async function markerOrders(context, producer) {
|
|
let last = null
|
|
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
|
last = await api(context, 'GET', `等待本轮尾程订单落地(${attempt}/60)`,
|
|
query(ORDERS, { page: 1, size: 200, containerNo: producer.containerNo }))
|
|
const matched = records(last.data).filter((row) => id(row?.containerNo) === id(producer.containerNo)
|
|
&& id(row?.customerId) === id(producer.tenantId) && id(row?.id))
|
|
if (matched.length > 0) return { rows: matched, response: last.response }
|
|
if (attempt < 60) await new Promise((resolve) => setTimeout(resolve, 1_000))
|
|
}
|
|
return { rows: [], response: last?.response || null }
|
|
}
|
|
async function detail(context, row, name = '读取尾程订单即时详情') {
|
|
return api(context, 'GET', name,
|
|
query(`${ORDERS}/${encodeURIComponent(id(row.id))}`, { customerId: id(row.customerId) }))
|
|
}
|
|
async function ensureCurrentUsdRate(context) {
|
|
const month = new Date().toISOString().slice(0, 7)
|
|
const path = `/api/platform/v1/last-leg/master-data/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}`
|
|
const listed = await api(context, 'GET', '查询尾程费用业务月汇率', path)
|
|
const existing = records(listed.data).find((row) => row?.month === month)
|
|
if (existing?.status === 'ACTIVE' && Number(existing?.usdToCnyAverage) > 0) return month
|
|
if (existing) {
|
|
blocker(context, '尾程费用业务月汇率', `业务月${month}汇率存在但不是ACTIVE,拒绝覆盖既有主数据`, listed.response)
|
|
return null
|
|
}
|
|
const created = await api(context, 'POST', '补齐尾程费用业务月汇率',
|
|
'/api/platform/v1/last-leg/master-data/exchange-rates', {
|
|
month, usdToCnyAverage: 7.1, remark: `${context.marker} acceptance rate`,
|
|
})
|
|
context.assert(MODULE, '尾程费用业务月汇率可用于USD兑CNY', created.response,
|
|
created.data?.month === month && created.data?.status === 'ACTIVE'
|
|
&& Number(created.data?.usdToCnyAverage) > 0,
|
|
`month=${month}, status=${created.data?.status || 'missing'}`, OPTIONS)
|
|
return created.data?.status === 'ACTIVE' ? month : null
|
|
}
|
|
async function verifyReceivingWarehouseChange(context, producer, details) {
|
|
const bookingId = id(producer.containerBookingId)
|
|
if (!bookingId) {
|
|
blocker(context, '取得本轮来源柜预订ID', `containerNo=${producer.containerNo}`)
|
|
return false
|
|
}
|
|
const progressPath = `${ORDERS}/container-summaries/${encodeURIComponent(bookingId)}`
|
|
const current = await api(context, 'GET', '读取本轮柜级入仓计划进度', progressPath)
|
|
const summary = current.data?.summary
|
|
const originalWarehouseId = id(summary?.overseasWarehouseId)
|
|
if (!success(current.response) || !originalWarehouseId || summary?.canChangeReceivingWarehouse !== true) {
|
|
blocker(context, '柜级接柜仓可变更前置',
|
|
`bookingId=${bookingId}, warehouseId=${originalWarehouseId || 'missing'}, canChange=${summary?.canChangeReceivingWarehouse}`, current.response)
|
|
return false
|
|
}
|
|
const warehouses = await domesticApi(context, '查询可用公共海外接柜仓',
|
|
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
|
|
const alternate = records(warehouses.data).find((row) => id(row?.id) && id(row.id) !== originalWarehouseId
|
|
&& row?.warehouseType === 'OVERSEAS' && row?.status === 'ACTIVE')
|
|
if (!alternate) {
|
|
blocker(context, '柜级接柜仓真实切换候选',
|
|
`originalWarehouseId=${originalWarehouseId}; 至少需要2个ACTIVE公共OVERSEAS接柜仓`, warehouses.response)
|
|
return false
|
|
}
|
|
const changePath = `${progressPath}/receiving-warehouse`
|
|
const changeKey = `${context.marker}-receiving-warehouse-change`.slice(0, 128)
|
|
const changeBody = { overseasWarehouseId: id(alternate.id), expectedVersion: Number(summary.assignmentVersion), remark: context.marker }
|
|
const changed = await api(context, 'PUT', '整柜切换到另一公共接柜仓', changePath, changeBody,
|
|
{ 'Idempotency-Key': changeKey })
|
|
context.assert(MODULE, '接柜仓变更原子推进版本并保持柜内订单数', changed.response,
|
|
id(changed.data?.overseasWarehouseId) === id(alternate.id)
|
|
&& Number(changed.data?.assignmentVersion) === Number(summary.assignmentVersion) + 1
|
|
&& Number(changed.data?.totalOrderCount) === details.length,
|
|
trace(changed.response), OPTIONS)
|
|
if (!success(changed.response)) return false
|
|
const replay = await api(context, 'PUT', '接柜仓变更同键同载荷重放', changePath, changeBody,
|
|
{ 'Idempotency-Key': changeKey })
|
|
context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response,
|
|
Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion)
|
|
&& id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS)
|
|
const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` },
|
|
{ 'Idempotency-Key': changeKey }, context.overseasToken)
|
|
context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
|
const stale = await context.requestOverseas('PUT', changePath,
|
|
{ overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker },
|
|
{ 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken)
|
|
context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS)
|
|
const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath,
|
|
{ overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` },
|
|
{ 'Idempotency-Key': `${context.marker}-receiving-warehouse-restore`.slice(0, 128) })
|
|
context.assert(MODULE, '接柜仓恢复后供后续PDA消费', restored.response,
|
|
id(restored.data?.overseasWarehouseId) === originalWarehouseId
|
|
&& Number(restored.data?.assignmentVersion) === Number(changed.data.assignmentVersion) + 1,
|
|
trace(restored.response), OPTIONS)
|
|
return success(restored.response)
|
|
}
|
|
|
|
async function verifyDestinationMergeFeeAndExport(context, details, producer) {
|
|
const fbaResult = await domesticApi(context, '查询公共ACTIVE FBA目的仓',
|
|
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&warehouseType=FBA&status=ACTIVE')
|
|
const fba = records(fbaResult.data).find((row) => id(row?.id) && nonBlank(row?.warehouseCode)
|
|
&& nonBlank(row?.warehouseName) && row?.warehouseType === 'FBA' && row?.status === 'ACTIVE')
|
|
if (!fba) {
|
|
blocker(context, '尾程目的仓变更权威主档', '国内Warehouse未返回公共ACTIVE FBA仓', fbaResult.response)
|
|
return false
|
|
}
|
|
const destination = { warehouseId: id(fba.id), warehouseCode: fba.warehouseCode,
|
|
warehouseName: fba.warehouseName, remark: context.marker }
|
|
const representative = details[0]
|
|
const one = await api(context, 'PUT', '单票变更FBA目的仓',
|
|
query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/destination`, { customerId: id(representative.customerId) }), destination)
|
|
context.assert(MODULE, '单票目的仓变更返回1', one.response, Number(one.data) === 1, trace(one.response), OPTIONS)
|
|
if (!success(one.response)) return false
|
|
const batchOrders = details.slice(1).length > 0 ? details.slice(1) : [representative]
|
|
const batch = await api(context, 'PUT', '批量变更FBA目的仓',
|
|
query(`${ORDERS}/destination`, { customerId: id(representative.customerId) }),
|
|
{ ...destination, orderIds: batchOrders.map((row) => id(row.id)) })
|
|
context.assert(MODULE, '批量目的仓变更数量精确', batch.response,
|
|
Number(batch.data) === batchOrders.length, trace(batch.response), OPTIONS)
|
|
if (!success(batch.response)) return false
|
|
|
|
const mergePath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/pallet-merge-required`, {
|
|
customerId: id(representative.customerId),
|
|
})
|
|
const marked = await api(context, 'PUT', '单票开启PDA合托提示', mergePath, { palletMergeRequired: true })
|
|
context.assert(MODULE, '单票合托标记写入成功', marked.response, marked.data == null, trace(marked.response), OPTIONS)
|
|
const markedRead = await detail(context, representative, '回查单票合托标记')
|
|
context.assert(MODULE, '单票合托标记为REQUIRED', markedRead.response,
|
|
markedRead.data?.order?.palletMergeFlag === 'REQUIRED', trace(markedRead.response), OPTIONS)
|
|
const unmarked = await api(context, 'PUT', '单票取消PDA合托提示', mergePath, { palletMergeRequired: false })
|
|
context.assert(MODULE, '单票合托标记可取消', unmarked.response, unmarked.data == null, trace(unmarked.response), OPTIONS)
|
|
|
|
const allIds = details.map((row) => id(row.id))
|
|
const batchMergePath = query(`${ORDERS}/pallet-merge-required/batch`, { customerId: id(representative.customerId) })
|
|
const batchMarked = await api(context, 'PUT', '批量开启PDA合托分组', batchMergePath,
|
|
{ orderIds: allIds, palletMergeRequired: true })
|
|
context.assert(MODULE, '批量合托分组写入成功', batchMarked.response, batchMarked.data == null, trace(batchMarked.response), OPTIONS)
|
|
const groupRead = await detail(context, representative, '回查批量合托关系')
|
|
context.assert(MODULE, '批量合托关系包含本轮订单', groupRead.response,
|
|
groupRead.data?.order?.palletMergeFlag === 'REQUIRED'
|
|
&& (allIds.length === 1 || nonBlank(groupRead.data?.order?.palletMergeBatchId)),
|
|
`orders=${allIds.length}, batchId=${groupRead.data?.order?.palletMergeBatchId || 'single-order'}; ${trace(groupRead.response)}`,
|
|
OPTIONS)
|
|
const batchCleared = await api(context, 'PUT', '批量取消PDA合托分组', batchMergePath,
|
|
{ orderIds: allIds, palletMergeRequired: false })
|
|
context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS)
|
|
|
|
const feeMonth = await ensureCurrentUsdRate(context)
|
|
if (!feeMonth) return false
|
|
const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128)
|
|
const fee = await api(context, 'POST', '新增本轮尾程订单费用',
|
|
query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }),
|
|
{ feeItemCode: feeItem.slice(0, 64), feeItemName: feeItem, direction: 'RECEIVABLE',
|
|
currency: 'USD', amount: 0.01, businessDate: `${feeMonth}-01` },
|
|
{ 'Idempotency-Key': `${context.marker}-tail-fee`.slice(0, 128) })
|
|
context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response,
|
|
id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemName === feeItem
|
|
&& fee.data?.originalCurrency === 'USD' && Number(fee.data?.originalAmount) === 0.01
|
|
&& fee.data?.currency === 'CNY' && Number(fee.data?.amount) > 0
|
|
&& fee.data?.reviewStatus === 'PENDING',
|
|
trace(fee.response), OPTIONS)
|
|
const feeRead = await detail(context, representative, '回查尾程订单费用明细')
|
|
context.assert(MODULE, '尾程费用详情可追溯', feeRead.response,
|
|
feeRead.data?.fees?.some((row) => row?.feeItemName === feeItem
|
|
&& row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01
|
|
&& row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS)
|
|
|
|
const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, {
|
|
customerId: id(representative.customerId), containerNo: producer.containerNo,
|
|
}))
|
|
const csv = new TextDecoder().decode(exported.bytes || new Uint8Array())
|
|
context.addStep(MODULE, '导出本轮尾程订单CSV', exported,
|
|
exported?.http === 200 && String(exported?.contentType || '').toLowerCase().includes('csv')
|
|
&& csv.includes(producer.containerNo) && csv.includes('尾程单号'),
|
|
`bytes=${exported?.bytes?.length ?? 0}, containerNo=${producer.containerNo}; ${trace(exported)}`, OPTIONS)
|
|
return success(batchCleared.response) && success(fee.response) && exported?.http === 200
|
|
}
|
|
function manualPlanBody(order, version, marker) {
|
|
return {
|
|
customerId: id(order.customerId), orderId: id(order.id), expectedOrderVersion: Number(version),
|
|
inboundCode: order.inboundCode,
|
|
palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)),
|
|
palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true,
|
|
remark: marker,
|
|
}
|
|
}
|
|
function completeManualNodes(marker) {
|
|
const base = Date.now() - 8 * 60 * 60_000
|
|
return NODE_CODES.map((nodeCode, index) => ({
|
|
nodeCode, nodeAt: localIso(new Date(base + index * 60 * 60_000)),
|
|
})).map((node, index) => index === 3 ? { ...node, nodeAt: null } : node)
|
|
}
|
|
|
|
function previewGroupRank(group) {
|
|
return ({ FBA: 0, ONE_CLICK_DROPSHIP: 1, PRIVATE_ADDRESS: 2 })[group?.destinationType] ?? 99
|
|
}
|
|
|
|
function previewGroupsSorted(groups) {
|
|
const rows = Array.isArray(groups) ? groups : []
|
|
return rows.every((group, index) => {
|
|
if (index === 0) return true
|
|
const previous = rows[index - 1]
|
|
const rankDifference = previewGroupRank(previous) - previewGroupRank(group)
|
|
return rankDifference < 0 || (rankDifference === 0
|
|
&& String(previous?.destinationWarehouseCode || '').localeCompare(
|
|
String(group?.destinationWarehouseCode || '')) <= 0)
|
|
})
|
|
}
|
|
|
|
async function convertContainerPlans(context, producer, details) {
|
|
const bookingId = id(producer.containerBookingId)
|
|
const previewPath = query(`${PLANS}/container-preview`, { sourceContainerBookingId: bookingId })
|
|
const preview = await api(context, 'GET', '预览整柜待生成入仓计划清单', previewPath)
|
|
const groups = Array.isArray(preview.data?.groups) ? preview.data.groups : []
|
|
const previewOrders = groups.flatMap((group) => Array.isArray(group?.orders) ? group.orders : [])
|
|
const pendingPalletCount = Number(preview.data?.pendingPalletCount)
|
|
context.assert(MODULE, '柜级预览列清尾程单、入仓码、目的仓、业务类型与建议托数', preview.response,
|
|
preview.data?.ready === true && id(preview.data?.sourceContainerBookingId) === bookingId
|
|
&& previewOrders.length === details.length && pendingPalletCount > 0
|
|
&& previewOrders.every((order) => nonBlank(order?.businessOrderNo) && nonBlank(order?.inboundCode)
|
|
&& nonBlank(order?.destinationType) && nonBlank(order?.destinationWarehouseCode)
|
|
&& Number(order?.suggestedPalletCount) > 0 && order?.readiness === 'READY'
|
|
&& Array.isArray(order?.blockers) && order.blockers.length === 0),
|
|
`orders=${previewOrders.length}, pendingPallets=${pendingPalletCount}, groups=${groups.length}; ${trace(preview.response)}`,
|
|
OPTIONS)
|
|
context.assert(MODULE, '柜级预览按FBA、一件代发、私人地址及目的仓稳定排序', preview.response,
|
|
previewGroupsSorted(groups), groups.map((group) => `${group.destinationType}:${group.destinationWarehouseCode}`).join(','), OPTIONS)
|
|
context.assert(MODULE, '一件代发的FBA目的仓快照保留给自动托盘', preview.response,
|
|
previewOrders.filter((order) => order?.destinationType === 'ONE_CLICK_DROPSHIP'
|
|
&& nonBlank(order?.destinationWarehouseCode)).every((order) => nonBlank(order?.fbaWarehouseCode)),
|
|
`dropshipFba=${previewOrders.filter((order) => nonBlank(order?.fbaWarehouseCode)).length}`, OPTIONS)
|
|
if (!success(preview.response) || preview.data?.ready !== true || pendingPalletCount <= 0) return null
|
|
|
|
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.data.snapshotVersion),
|
|
snapshotHash: preview.data.snapshotHash }
|
|
const key = `${context.marker}-container-plan-confirm`.slice(0, 128)
|
|
const converted = await api(context, 'POST', '按柜确认并自动生成入仓计划与实体托盘',
|
|
`${PLANS}/convert-container`, body, { 'Idempotency-Key': key })
|
|
const plans = Array.isArray(converted.data) ? converted.data : []
|
|
const pallets = plans.flatMap((plan) => Array.isArray(plan?.pallets) ? plan.pallets.map((pallet) => ({ plan, pallet })) : [])
|
|
context.assert(MODULE, '按柜确认逐票生成独立计划且托盘总数与预览一致', converted.response,
|
|
plans.length === details.length && pallets.length === pendingPalletCount
|
|
&& plans.every((plan) => plan?.creationMode === 'AUTO' && nonBlank(plan?.planNo)
|
|
&& Array.isArray(plan?.lines) && plan.lines.length === 1)
|
|
&& pallets.every(({ pallet }) => nonBlank(pallet?.palletNo) && id(pallet?.warehousePalletId)
|
|
&& id(pallet?.warehouseLocationId) && pallet?.assignmentStatus === 'RESERVED'),
|
|
`plans=${plans.length}, pallets=${pallets.length}, expectedPallets=${pendingPalletCount}; ${trace(converted.response)}`,
|
|
OPTIONS)
|
|
if (!success(converted.response)) return null
|
|
|
|
for (const { plan, pallet } of pallets) {
|
|
const line = plan.lines[0]
|
|
const palletPath = query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, {
|
|
page: 1, size: 10, keyword: pallet.palletNo,
|
|
})
|
|
const listed = await api(context, 'GET', `回查自动托盘${pallet.palletNo}`, palletPath)
|
|
const stored = records(listed.data).find((row) => row?.palletCode === pallet.palletNo)
|
|
context.assert(MODULE, '自动托盘进入托盘列表并保留FBA目的仓', listed.response,
|
|
id(stored?.id) === id(pallet.warehousePalletId) && stored?.status === 'OCCUPIED'
|
|
&& (!nonBlank(line?.fbaWarehouseCode) || stored?.fbaWarehouseCode === line.fbaWarehouseCode),
|
|
`pallet=${pallet.palletNo}, expectedFba=${line?.fbaWarehouseCode || 'none'}, actualFba=${stored?.fbaWarehouseCode || 'none'}; ${trace(listed.response)}`,
|
|
OPTIONS)
|
|
}
|
|
|
|
const replay = await api(context, 'POST', '按柜确认同键同快照幂等重放',
|
|
`${PLANS}/convert-container`, body, { 'Idempotency-Key': key })
|
|
context.assert(MODULE, '按柜确认重放不新增计划或托盘', replay.response,
|
|
JSON.stringify(replay.data) === JSON.stringify(converted.data), trace(replay.response), OPTIONS)
|
|
const after = await api(context, 'GET', '回查整柜计划与托盘均已生成', previewPath)
|
|
context.assert(MODULE, '确认后预览待生成票数和托数归零', after.response,
|
|
Number(after.data?.pendingOrderCount) === 0 && Number(after.data?.pendingPalletCount) === 0
|
|
&& Number(after.data?.totalPlannedPalletCount) >= pendingPalletCount,
|
|
`pendingOrders=${after.data?.pendingOrderCount}, pendingPallets=${after.data?.pendingPalletCount}, totalPlannedPallets=${after.data?.totalPlannedPalletCount}; ${trace(after.response)}`,
|
|
OPTIONS)
|
|
return plans
|
|
}
|
|
|
|
export async function run(context) {
|
|
context.createdResources.phase4TailOrderControls = {
|
|
marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null,
|
|
finalStatus: null,
|
|
cleanup: 'Automatically generated container inbound plans and pallets remain as marker-owned prerequisites consumed by overseas PDA receiving',
|
|
}
|
|
const producer = targetProducer(context)
|
|
if (!producer?.containerNo || !producer?.tenantId) {
|
|
blocker(context, '取得本轮尾程控制目标柜', '缺少本轮phase4入仓生产者,禁止借历史尾程订单')
|
|
return
|
|
}
|
|
const listed = await markerOrders(context, producer)
|
|
if (listed.rows.length === 0) {
|
|
blocker(context, '取得本轮尾程订单', `containerNo=${producer.containerNo}, customerId=${producer.tenantId}`, listed.response)
|
|
return
|
|
}
|
|
const details = []
|
|
for (const row of listed.rows) {
|
|
const current = await detail(context, row)
|
|
if (success(current.response) && id(current.data?.order?.id) === id(row.id)) details.push(current.data.order)
|
|
}
|
|
if (details.length !== listed.rows.length || details.some((order) => !order.inboundCode
|
|
|| !order.overseasWarehouseId)) {
|
|
blocker(context, '尾程订单人工计划冻结事实完整', `listed=${listed.rows.length}, detailed=${details.length}`)
|
|
return
|
|
}
|
|
if (!await verifyReceivingWarehouseChange(context, producer, details)) return
|
|
if (!await verifyDestinationMergeFeeAndExport(context, details, producer)) return
|
|
const refreshedDetails = []
|
|
for (const original of details) {
|
|
const refreshed = await detail(context, original, `管理命令后刷新订单${id(original.id)}版本`)
|
|
if (!success(refreshed.response) || !refreshed.data?.order) return
|
|
refreshedDetails.push(refreshed.data.order)
|
|
}
|
|
const representative = refreshedDetails[0]
|
|
const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, {
|
|
customerId: id(representative.customerId),
|
|
})
|
|
const holdKey = `${context.marker}-tail-hold`.slice(0, 128)
|
|
const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) }
|
|
const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey })
|
|
context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response,
|
|
held.data?.held === true && held.data?.chargeable === true
|
|
&& id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1,
|
|
trace(held.response), OPTIONS)
|
|
if (!success(held.response)) return
|
|
const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey })
|
|
context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response,
|
|
JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS)
|
|
const heldConflict = await context.requestOverseas('PUT', orderPath,
|
|
{ ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken)
|
|
context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS)
|
|
|
|
const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker)
|
|
const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody,
|
|
{ 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken)
|
|
context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS)
|
|
|
|
const releaseKey = `${context.marker}-tail-release`.slice(0, 128)
|
|
const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) }
|
|
const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey })
|
|
context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response,
|
|
released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null
|
|
&& Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS)
|
|
if (!success(released.response)) return
|
|
|
|
const nodesPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/manual-nodes`, {
|
|
customerId: id(representative.customerId),
|
|
})
|
|
const nodesKey = `${context.marker}-tail-manual-nodes`.slice(0, 128)
|
|
const nodesBody = { expectedVersion: Number(released.data.version), nodes: completeManualNodes(context.marker) }
|
|
const nodes = await api(context, 'PUT', '保存尾程八个人工节点快照', nodesPath, nodesBody,
|
|
{ 'Idempotency-Key': nodesKey })
|
|
context.assert(MODULE, '人工节点永久切换MANUAL且完整返回八行', nodes.response,
|
|
nodes.data?.manualOverrideEnabled === true && nodes.data?.nodes?.length === 8
|
|
&& nodes.data.nodes.every((node) => node?.source === 'MANUAL')
|
|
&& Number(nodes.data?.version) === Number(released.data.version) + 1,
|
|
`nodes=${nodes.data?.nodes?.length ?? 'missing'}; ${trace(nodes.response)}`, OPTIONS)
|
|
if (!success(nodes.response)) return
|
|
const nodesReplay = await api(context, 'PUT', '人工节点同键同载荷重放', nodesPath, nodesBody,
|
|
{ 'Idempotency-Key': nodesKey })
|
|
context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response,
|
|
JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS)
|
|
const nodesConflict = await context.requestOverseas('PUT', nodesPath,
|
|
{ ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) },
|
|
{ 'Idempotency-Key': nodesKey }, context.overseasToken)
|
|
context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS)
|
|
const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath)
|
|
context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response,
|
|
nodesRead.data?.manualOverrideEnabled === true && nodesRead.data?.nodes?.length === 8
|
|
&& nodesRead.data.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS)
|
|
|
|
const generated = await convertContainerPlans(context, producer, refreshedDetails)
|
|
if (!generated) return
|
|
context.createdResources.phase4TailOrderControls = {
|
|
...context.createdResources.phase4TailOrderControls,
|
|
containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)),
|
|
planNos: generated.map((plan) => plan.planNo), representativeOrderId: id(representative.id),
|
|
generatedPlans: generated,
|
|
finalStatus: 'AUTO_CONTAINER_PLANS_READY',
|
|
}
|
|
}
|