import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' const MODULE = 'phase4-overseas-return-receipt' const SCENARIO = 'phase4-overseas-return-receipt-and-billing' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders' const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts' const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory' function id(value) { return value == null ? '' : String(value) } function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } function code(response) { return String(response?.body?.code ?? '') } function success(response) { return response?.http === 200 && code(response) === '200' } function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && nonBlank(response?.requestId) && nonBlank(response?.traceId) } function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && nonBlank(response?.requestId) && nonBlank(response?.traceId) } function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] } 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) { return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255) } function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() } function add(context, name, response, passed, evidence = '') { context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS) } async function overseas(context, token, method, name, path, body = undefined) { const response = await context.requestOverseas(method, path, body, {}, token) add(context, name, response, success(response)) return { response, data: success(response) ? response.body?.data : null } } async function resolveWarehouse(context) { const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId) const response = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') const rows = records(response?.body?.data) const fixedIds = new Set(['193000000000200003', '193000000000200004']) const warehouse = rows.find((item) => id(item?.id) === existing) || rows.find((item) => fixedIds.has(id(item?.id))) || rows[0] add(context, '读取可执行退货签收的海外仓', response, success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`) return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null } async function loginMerchant(context) { const fixture = context.context?.merchantQualificationFixture if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) { add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`) return null } const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password, }, '') const token = success(response) ? response.body?.data?.overseasAccessToken : null add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) return nonBlank(token) ? token : null } function itemMap(items) { return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item])) } export async function run(context) { context.createdResources.phase4OverseasReturnReceipt = { marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [], billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt', } const merchantToken = await loginMerchant(context) const warehouse = await resolveWarehouse(context) const access = await resolvePickerAccess(context) if (!merchantToken || !warehouse || !access) return const picker = await createPicker(context, 91, warehouse.id, access) if (!picker?.token) { add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌') if (picker?.membershipId) await disablePicker(context, picker, 91) return } const suffix = markerSuffix(context) const skuA = `RET-A-${suffix}`.slice(0, 128) const skuB = `RET-B-${suffix}`.slice(0, 128) const createBody = { overseasWarehouseId: warehouse.id, warehouseContactName: '全量验收海外仓', warehouseContactPhone: '0000000000', customerReferenceNo: `REF-${suffix}`, needRelabel: false, remark: context.marker, idempotencyKey: key(context, 'CREATE'), items: [ { skuCode: skuA, productNameZh: '退货验收商品A', expectedQuantity: 3 }, { skuCode: skuB, productNameZh: '退货验收商品B', expectedQuantity: 2 }, ], } try { const created = await overseas(context, merchantToken, 'POST', '客户创建双SKU退货预报', RETURN_ORDERS, createBody) const returnNo = id(created.data?.order?.returnNo) context.assert(MODULE, '退货预报初始数量和状态正确', created.response, nonBlank(returnNo) && created.data?.order?.status === 'PENDING_RECEIPT' && Number(created.data?.order?.expectedQuantity) === 5 && Number(created.data?.order?.receivedQuantity) === 0 && itemMap(created.data?.items).size === 2, `returnNo=${returnNo || 'missing'}`, OPTIONS) if (!returnNo) return context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody) context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response, id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS) const createConflict = await context.requestOverseas('POST', RETURN_ORDERS, { ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken) add(context, '退货预报同键异载荷返回冲突', createConflict, conflict(createConflict)) const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选', `${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` }) context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response, success(emptySku.response) && Array.isArray(emptySku.data?.candidates) && emptySku.data.candidates.length === 0, trace(emptySku.response), OPTIONS) const started = await overseas(context, picker.token, 'POST', 'PDA按退货单号开始签收', `${PDA_RECEIPTS}/start`, { returnNo }) context.assert(MODULE, '首次开始返回全部未收SKU', started.response, started.data?.status === 'PENDING_RECEIPT' && itemMap(started.data?.expectedItems).size === 2, trace(started.response), OPTIONS) const skuCandidates = await overseas(context, picker.token, 'POST', 'PDA按SKU查询当前仓候选退货单', `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB }) context.assert(MODULE, 'SKU候选包含本轮退货单及准确数量', skuCandidates.response, skuCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo && Number(candidate.skuExpectedQuantity) === 2 && Number(candidate.skuReceivedQuantity) === 0), trace(skuCandidates.response), OPTIONS) const cartonA = `RC-${suffix}-A`.slice(0, 128) const firstBody = { returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', cartonMark: cartonA, items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-1'), } const first = await overseas(context, picker.token, 'POST', 'PDA首次分批签收2件', `${PDA_RECEIPTS}/confirm`, firstBody) context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response, first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false && Number(first.data?.receivedQuantity) === 2 && first.data?.cartonMark === cartonA && nonBlank(first.data?.receiptNo) && nonBlank(first.data?.receiptBatchNo), `receiptNo=${id(first.data?.receiptNo) || 'missing'}, billingBatch=${id(first.data?.receiptBatchNo) || 'missing'}`, OPTIONS) const firstReceiptNo = id(first.data?.receiptNo) const firstBatchNo = id(first.data?.receiptBatchNo) const firstReplay = await overseas(context, picker.token, 'POST', '首次签收同键同载荷重放', `${PDA_RECEIPTS}/confirm`, firstBody) context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response, id(firstReplay.data?.receiptNo) === firstReceiptNo && id(firstReplay.data?.receiptBatchNo) === firstBatchNo && id(firstReplay.data?.cartonMark) === cartonA, trace(firstReplay.response), OPTIONS) const firstConflict = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, }, {}, picker.token) add(context, '签收同键异载荷返回冲突', firstConflict, conflict(firstConflict)) const overReceipt = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'OVER-RECEIPT'), }, {}, picker.token) add(context, '超收被拒绝且不得生成费用批次', overReceipt, conflict(overReceipt)) const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response, afterPartial.data?.order?.status === 'PARTIALLY_RECEIVED' && Number(afterPartial.data?.order?.receivedQuantity) === 2 && afterPartial.data?.receipts?.length === 1 && id(afterPartial.data.receipts[0]?.receiptBatchNo) === firstBatchNo, `receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS) const secondBody = { returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 1 }, { skuCode: skuB, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-2'), } const second = await overseas(context, picker.token, 'POST', 'PDA按SKU完成剩余3件签收', `${PDA_RECEIPTS}/confirm`, secondBody) context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response, second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true && Number(second.data?.receivedQuantity) === 5 && nonBlank(second.data?.cartonMark) && nonBlank(second.data?.receiptNo) && nonBlank(second.data?.receiptBatchNo) && id(second.data?.receiptBatchNo) !== firstBatchNo, `billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS) const secondReceiptNo = id(second.data?.receiptNo) const secondBatchNo = id(second.data?.receiptBatchNo) const cartonB = id(second.data?.cartonMark) context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo) context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo) context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB) const completedStart = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token) add(context, '已完成退货单不再进入待签收流程', completedStart, notFound(completedStart)) const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空', `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB }) context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response, !completedCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo), trace(completedCandidates.response), OPTIONS) const detail = await overseas(context, merchantToken, 'GET', '客户回查完成退货单与两次签收', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) const detailItems = itemMap(detail.data?.items) const detailReceipts = Array.isArray(detail.data?.receipts) ? detail.data.receipts : [] const detailBatches = new Set(detailReceipts.map((receipt) => id(receipt.receiptBatchNo))) context.assert(MODULE, '订单5件核平且恰有两个唯一仓储计费批次', detail.response, detail.data?.order?.status === 'COMPLETED' && Number(detail.data?.order?.receivedQuantity) === 5 && Number(detailItems.get(skuA)?.receivedQuantity) === 3 && Number(detailItems.get(skuB)?.receivedQuantity) === 2 && detailReceipts.length === 2 && detailBatches.size === 2 && detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo), `receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS) const merchantInventory = await context.requestOverseas('GET', query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), undefined, {}, merchantToken) add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory)) const customerId = id(context.context?.merchantQualificationFixture?.tenantId) const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存', query('/api/platform/v1/last-leg/return-inventory/cartons', { page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo, })) const inventoryRows = records(inventory.data) const sourceBatches = new Set(inventoryRows.map((row) => id(row.sourceBatchNo))) context.assert(MODULE, '两个实收箱逐一引用对应计费批次且数量守恒', inventory.response, inventoryRows.length === 2 && inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0) === 5 && sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo), `cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS) const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存', query('/api/platform/v1/last-leg/return-inventory', { page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, })) const summaries = new Map(records(summary.data).filter((row) => [skuA, skuB].includes(row.skuCode)) .map((row) => [row.skuCode, row])) context.assert(MODULE, '退货SKU汇总数量与两个实收箱一致', summary.response, Number(summaries.get(skuA)?.totalQuantity) === 3 && Number(summaries.get(skuB)?.totalQuantity) === 2, `skuA=${summaries.get(skuA)?.totalQuantity ?? 'missing'}, skuB=${summaries.get(skuB)?.totalQuantity ?? 'missing'}`, OPTIONS) for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 2 }, { cartonMark: cartonB, batchNo: secondBatchNo, quantity: 3 }].entries()) { const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`, `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`) context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response, carton.data?.sourceBatchNo === expected.batchNo && Number(carton.data?.totalQuantity) === expected.quantity && Array.isArray(carton.data?.items) && carton.data.items.length > 0, `batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS) const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`, `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`) context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response, label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo && Number(label.data?.totalQuantity) === expected.quantity && nonBlank(label.data?.printableHtml), trace(label.response), OPTIONS) } const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存', query('/api/pda/v1/last-leg/return-inventory/packing/orders', { returnNo })) const packingSkus = new Map((packing.data?.skus || []).map((row) => [row.skuCode, row])) context.assert(MODULE, '退货合箱候选只含本轮完成单且库存准确', packing.response, packing.data?.orders?.some?.((order) => order.returnNo === returnNo && order.status === 'COMPLETED') && Number(packingSkus.get(skuA)?.availableQuantity) === 3 && Number(packingSkus.get(skuB)?.availableQuantity) === 2, trace(packing.response), OPTIONS) const mergeBody = { returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }, { skuCode: skuB, quantity: 1 }], length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'), } const merged = await overseas(context, picker.token, 'POST', 'PDA将同客户退货库存合为新箱', '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) if (success(merged.response)) { context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response, nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2 && nonBlank(merged.data?.label?.sourceBatchNo) && merged.data?.label?.sourceBatchNo !== 'MERGED' && merged.data?.label?.sourceReturnNo === returnNo && Number(merged.data?.label?.items?.length) === 2 && nonBlank(merged.data?.label?.printableHtml), `cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS) const consumedBatches = [] for (const [mark, batch, before] of [[cartonA, firstBatchNo, 2], [cartonB, secondBatchNo, 3]]) { const source = await overseas(context, merchantToken, 'GET', '合箱后回查来源箱实际扣减', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(mark)}`) if (success(source.response) && Number(source.data?.totalQuantity) < before) consumedBatches.push(batch) } const actualBatches = String(merged.data?.label?.sourceBatchNo || '').split(',') context.assert(MODULE, '合箱来源批次精确等于实际消耗来源且去重', merged.response, consumedBatches.length > 0 && new Set(actualBatches).size === actualBatches.length && JSON.stringify([...consumedBatches].sort()) === JSON.stringify([...actualBatches].sort()), `expected=${consumedBatches.join(',')}, actual=${actualBatches.join(',')}`, OPTIONS) const cartonPage = await overseas(context, merchantToken, 'GET', '客户箱号管理按实物箱分页', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 20, returnNo })) context.assert(MODULE, '箱号分页包含本轮合箱且来源退货单保持真实业务号', cartonPage.response, records(cartonPage.data).some(row => row.cartonMark === merged.data.cartonMark && row.sourceReturnNo === returnNo && Number(row.totalQuantity) === 2), trace(cartonPage.response), OPTIONS) const label = await overseas(context, merchantToken, 'GET', '客户读取本轮合箱条码标签', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(merged.data.cartonMark)}/label`) context.assert(MODULE, '标签包含SVG条码和当前箱号', label.response, label.data?.cartonMark === merged.data.cartonMark && /