import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' const MODULE = 'phase4-overseas-return-followups' const SCENARIO = 'phase4-overseas-return-followups' 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) { const source = context.createdResources.phase4OverseasReturnReceipt if (source?.marker !== context.marker || !source?.returnNo) { add(context, '后续关联要求本轮退货签收合箱前置', null, false, '缺少本轮退货前置') return } 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, 92, warehouse.id, access) if (!picker?.token) { add(context, '创建后续关联PDA操作员', null, false, '未取得绑定仓PDA令牌') if (picker?.membershipId) await disablePicker(context, picker, 92) return } const returnNo = source.returnNo const customerId = id(context.context?.merchantQualificationFixture?.tenantId) const suffix = markerSuffix(context) try { const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`) const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : []) .find(row => source.cartonMarks?.includes(row?.cartonMark) && row?.status === 'AVAILABLE') context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response, Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo, `cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS) if (relabelCandidate) { let destination for (let page = 1; page <= 20; page += 1) { const destinations = await context.request('GET', `/api/platform/v1/warehouse-admin?page=${page}&size=200&warehouseType=FBA&status=ACTIVE`) if (!success(destinations)) throw new Error('读取FBA目的仓失败') const data = destinations.body.data destination = records(data).find(row => row.warehouseCode === 'SCK8' && row.status === 'ACTIVE') if (destination || page * 200 >= Number(data.total)) break if (!records(data).length) throw new Error('FBA目的仓分页不完整') } if (!destination?.id) throw new Error('完整分页未找到启用的SCK8目的仓,停止退货转换标') const sku = relabelCandidate.items?.[0]?.skuCode if (!sku) throw new Error('本轮候选缺少SKU') const relabelBody = { items: [{ skuCode: sku, quantity: 1 }], idempotencyKey: key(context, 'RELABEL'), fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: id(destination.id), destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: context.marker, } const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody) const relabelNo = id(relabel.data?.relabelNo) context.assert(MODULE, '退货换标单绑定原退货单与选中SKU', relabel.response, nonBlank(relabelNo) && relabel.data?.returnNo === returnNo && relabel.data?.status === 'PENDING_REVIEW' && relabel.data?.skuCodes?.includes?.(sku), `relabelNo=${relabelNo || 'missing'}`, OPTIONS) if (relabelNo) { const form = new FormData() form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)], { type: 'application/pdf' }), `${suffix}-return-relabel.pdf`) const uploaded = await context.requestOverseasMultipart('POST', `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`, form, merchantToken) add(context, '客户编辑退货换标总资料', uploaded, success(uploaded)) const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改', `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`) context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response, relabelDetail.data?.application?.relabelNo === relabelNo && relabelDetail.data?.application?.status === 'PENDING_REVIEW' && nonBlank(relabelDetail.data?.application?.cartonTotalFileId), trace(relabelDetail.response), OPTIONS) context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo } } const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选', query('/api/platform/v1/last-leg/outbound-orders/return/candidates', { customerId, warehouseId: warehouse.id, })) const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : []) .find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark) && row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE') context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response, Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS) if (outboundCandidate) { const outboundBody = { recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id, returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker, idempotencyKey: key(context, 'OUTBOUND'), } const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单', query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody) const taskNo = id(outbound.data?.taskNo) const outboundNo = id(outbound.data?.outboundNo) context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response, nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING', `outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS) if (taskNo) { const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细', `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`) const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark)) || task.data?.items?.[0] const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId, inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark], idempotencyKey: key(context, 'OUTBOUND-PICK') } const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货', `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody) context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response, picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS) const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库', `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`, { idempotencyKey: key(context, 'OUTBOUND-COMPLETE') }) const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态', query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId })) context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response, completed.data?.status === 'COMPLETED' && outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND' && outboundDetail.data?.outbound?.status === 'OUTBOUNDED' && outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark && Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS) context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo } } if (source.relabelNo) await completeReturnRelabel(context, merchantToken, picker.token, source.relabelNo, returnNo) } finally { await disablePicker(context, picker, 92) } } export async function completeReturnRelabel(context, merchantToken, pdaToken, relabelNo, returnNo) { const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(relabelNo)}` const pallets = '/api/pda/v1/last-leg/pallet-operations' const command = async (token, method, name, path, body) => { const result = await overseas(context, token, method, name, path, body) if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`) return result.data } const require = (name, passed) => { context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabelNo}`, OPTIONS) if (!passed) throw new Error(name) } const reviewed = await command(context.overseasToken, 'PUT', '审核本轮退货换标申请', `${platform}/status`, { status: 'PENDING_PROCESSING', remark: context.marker }) require('换标审核后等待PDA作业', reviewed.application?.relabelNo === relabelNo && reviewed.application.status === 'PENDING_PROCESSING') let task = await command(pdaToken, 'POST', 'PDA认领本轮退货换标', `${work}/claim`, { idempotencyKey: key(context, 'RETURN-CLAIM') }) require('本轮任务全部为退货SKU换标', task.relabelNo === relabelNo && task.items?.length > 0 && task.items.every(item => item.operationType === 'RETURN_BULK_SKU_LABEL')) for (const item of task.items) { task = await command(pdaToken, 'POST', 'PDA完成本轮退货SKU数量', `${work}/return-skus/complete`, { itemId: item.itemId, quantity: Number(item.expectedPackages), expectedVersion: Number(task.version), idempotencyKey: key(context, `RETURN-SKU-${item.itemId}`), }) } const sources = await command(pdaToken, 'POST', '读取本轮换标实际来源托盘', `${pallets}/relabel-sources`, { relabelNo }) require('退货换标具有明确来源托盘', Array.isArray(sources) && sources.length > 0) for (const [index, source] of sources.entries()) { const target = await command(pdaToken, 'POST', '生成本轮带目的仓换标托盘', `${pallets}/relabel-pallet-label`, { relabelNo, idempotencyKey: key(context, `RETURN-PALLET-${index}`) }) const from = await command(pdaToken, 'POST', '回查来源托盘版本', `${pallets}/resolve`, { code: source.palletCode }) const to = await command(pdaToken, 'POST', '回查目标托盘版本', `${pallets}/resolve`, { code: target.palletCode }) require('组托仅使用本轮任务箱唛', source.cartonMarks?.length > 0 && source.cartonMarks.every(mark => task.items.some(item => item.cartonMark === mark))) const body = { relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) }, target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) }, cartonMarks: source.cartonMarks, idempotencyKey: key(context, `RETURN-BIND-${index}`), reason: context.marker } const bound = await command(pdaToken, 'POST', '退货换标按实际数量绑定目标托盘', `${pallets}/relabel-disassemble`, body) const replay = await command(pdaToken, 'POST', '退货换标组托原键重放', `${pallets}/relabel-disassemble`, body) require('换标组托重放不重复移动库存', bound.status === 'COMPLETED' && replay.replayed === true && id(bound.operationId) === id(replay.operationId)) } task = await command(pdaToken, 'GET', '组托后回查换标任务版本', work) const form = new FormData() form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-relabel.png') const photo = await context.requestOverseasMultipart('POST', `${work}/photos/files`, form, pdaToken) add(context, '上传合成换标完工照片', photo, success(photo)) if (!success(photo) || !photo.body.data?.fileId) throw new Error('换标完工照片失败') task = await command(pdaToken, 'POST', 'PDA提交退货换标完工', `${work}/complete`, { photoFileIds: [id(photo.body.data.fileId)], expectedVersion: Number(task.version), idempotencyKey: key(context, 'RETURN-COMPLETE'), remark: context.marker, }) require('换标完工等待客户确认', task.applicationStatus === 'CUSTOMER_CONFIRM_PENDING') const body = { idempotencyKey: key(context, 'RETURN-CONFIRM'), remark: context.marker } const confirmed = await command(merchantToken, 'POST', '客户确认本轮退货换标完成', `${merchant}/confirm-completion`, body) const replay = await command(merchantToken, 'POST', '客户确认换标原键重放', `${merchant}/confirm-completion`, body) require('退货SKU换标客户确认完成且不重复建单', confirmed.application?.status === 'COMPLETED' && replay.application?.relabelNo === relabelNo && replay.application?.status === 'COMPLETED') const order = await command(merchantToken, 'GET', '回查原退货单换标状态', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) require('原退货单保持签收完成且关联同一已完成换标', order.order?.status === 'COMPLETED' && order.order?.relabelNo === relabelNo && order.order?.relabelStatus === 'COMPLETED') context.createdResources.phase4OverseasReturnReceipt.relabelCompleted = true }