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 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' && nonBlank(groupRead.data?.order?.palletMergeBatchId), 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 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) }), { feeItem, currency: 'USD', amount: 0.01, status: 'PENDING_REVIEW', supplierDescription: context.marker }) context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response, id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItem === feeItem && fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 && fee.data?.status === 'PENDING_REVIEW', trace(fee.response), OPTIONS) const feeRead = await detail(context, representative, '回查尾程订单费用明细') context.assert(MODULE, '尾程费用详情可追溯', feeRead.response, feeRead.data?.fees?.some((row) => row?.feeItem === feeItem && Number(row?.amount) === 0.01), 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), plannedArrivalDate: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10), 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) } export async function run(context) { context.createdResources.phase4TailOrderControls = { marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null, finalStatus: null, cleanup: 'Manual inbound plans remain as the 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 plans = [] for (const original of refreshedDetails) { const refreshed = await detail(context, original, `创建人工计划前刷新订单${id(original.id)}版本`) const order = refreshed.data?.order if (!success(refreshed.response) || !order) return const body = manualPlanBody(order, order.version, context.marker) const key = `${context.marker}-manual-plan-${id(order.id)}`.slice(0, 128) const created = await api(context, 'POST', `为本轮尾程订单${id(order.id)}创建人工计划`, `${PLANS}/manual`, body, { 'Idempotency-Key': key }) context.assert(MODULE, '人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', created.response, Boolean(created.data?.planNo) && created.data?.creationMode === 'MANUAL' && Boolean(created.data?.overseasWarehouseCode) && Boolean(created.data?.overseasWarehouseName) && created.data?.plannedArrivalDate === body.plannedArrivalDate && created.data?.palletType === body.palletType && created.data?.urgent === true && Number(created.data?.palletCount) === body.palletCount && created.data?.lines?.some((line) => id(line?.orderId) === id(order.id) && line?.inboundCode === order.inboundCode), `orderId=${id(order.id)}, planNo=${created.data?.planNo || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!success(created.response) || !created.data?.planNo) return const replay = await api(context, 'POST', `人工计划${created.data.planNo}幂等重放`, `${PLANS}/manual`, body, { 'Idempotency-Key': key }) context.assert(MODULE, '人工计划重放复用同一计划且零新增', replay.response, replay.data?.planNo === created.data.planNo, trace(replay.response), OPTIONS) if (plans.length === 0) { const hashConflict = await context.requestOverseas('POST', `${PLANS}/manual`, { ...body, remark: `${context.marker}-DIFFERENT` }, { 'Idempotency-Key': key }, context.overseasToken) context.addStep(MODULE, '人工计划同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) } plans.push(created.data.planNo) } context.createdResources.phase4TailOrderControls = { ...context.createdResources.phase4TailOrderControls, containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)), planNos: plans, representativeOrderId: id(representative.id), finalStatus: 'MANUAL_PLANS_READY', } }