import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.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.requestOverseas(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.requestOverseas(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.requestOverseas(method, path, body, {}, token) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, 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?.overseasAccessToken), `tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.overseasAccessToken)) 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.overseasAccessToken : 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 } } 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, 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 updateBody = { vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64), driverName: `五期验收司机-${context.marker.slice(-6)}`, driverPhone: '19900000000', carrierName: '五期开发验收车队', route: `${context.marker}-PHASE5-ROUTE`, 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 === updateBody.route, trace(updated.response), OPTIONS) if (!success(updated.response)) return 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: 'MEDIUM', driverName: updateBody.driverName, driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`, fees: [{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }], } const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) const outboundNo = id(transferred.data?.outboundNo) const deliveryNo = id(transferred.data?.deliveryNo) context.assert(MODULE, '转派送同时生成WAIT_OUTBOUND与WAIT_DELIVERY单据', transferred.response, nonBlank(outboundNo) && nonBlank(deliveryNo) && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' && transferred.data?.deliveryStatus === 'WAIT_DELIVERY' && Number(transferred.data?.packageCount) > 0, `outboundNo=${outboundNo || 'missing'}, deliveryNo=${deliveryNo || 'missing'}; ${trace(transferred.response)}`, OPTIONS) if (!success(transferred.response) || !outboundNo || !deliveryNo) return Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo }) 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 && id(transferReplay.data?.deliveryNo) === deliveryNo, trace(transferReplay.response), OPTIONS) const transferConflict = await context.requestOverseas('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, 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, '客户派送列表包含本轮单据且不依赖customerId参数', merchantPage.response, merchantRows.some((value) => id(value?.deliveryNo) === deliveryNo && 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 && 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 try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') return } if (!await pickAll(context, picker.token, dispatchNo)) 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) } 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) const delivering = await platform(context, 'PUT', '派送单推进配送中', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, { customerId }), { status: 'DELIVERING', remark: `${context.marker}-delivering` }) context.assert(MODULE, '派送状态进入DELIVERING', delivering.response, delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) const completed = await platform(context, 'POST', '确认派送完成并生成客户回访', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId })) const visitId = id(completed.data?.visitId) context.assert(MODULE, '派送完成生成PENDING回访记录', completed.response, completed.data?.deliveryStatus === 'DELIVERED' && completed.data?.visitStatus === 'PENDING' && /^\d+$/.test(visitId), `visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(completed.response)}`, OPTIONS) if (!success(completed.response) || !visitId) return context.createdResources.phase5OverseasFulfillment.visitId = visitId const completeReplay = await platform(context, 'POST', '确认派送完成重复调用', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId })) context.assert(MODULE, '派送完成重放复用同一回访', completeReplay.response, completeReplay.data?.replayed === true && id(completeReplay.data?.visitId) === visitId, trace(completeReplay.response), OPTIONS) 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 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 manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常', `${LASTLEG_BASE}/exception-orders`, { customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo, exceptionType: 'DAMAGED', reason: `${context.marker}-manual-exception`, packageCount: 1, skuCode: null, }) 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) => id(value?.inboundOrderId) === id(inbound.inboundOrderId)) : null context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response, Boolean(returnInbound), `candidate=${Boolean(returnInbound)}; ${trace(returnOptions.response)}`, OPTIONS) if (!returnInbound) return const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), { inboundOrderId: id(returnInbound.inboundOrderId), reason: `${context.marker}-delivery-return`, returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128), packageCount: 1, skuCode: null, }) 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 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) context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT' }