const MODULE = 'phase4-overseas-client-services' const SCENARIO = 'phase4-overseas-client-services-lifecycle' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const ORDERS = '/api/merchant/v1/last-leg/orders' const RELABEL = '/api/merchant/v1/last-leg/relabel-applications' const TRANSFER = '/api/merchant/v1/last-leg/warehouse-transfers' const DISCARD = '/api/merchant/v1/last-leg/cargo-discards' function id(value) { return value == null ? '' : String(value) } function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } 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 forbidden(response) { return response?.http === 403 || (response?.http === 200 && codeOf(response) === 'A0403') } function records(value) { return Array.isArray(value?.records) ? value.records : [] } 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 } function future(days) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19) } async function merchant(context, token, method, name, path, body) { const response = await context.requestOverseas(method, path, body, {}, token) 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 }) } async function loginMerchant(context) { const fixture = context.context?.merchantQualificationFixture const pda = context.createdResources?.phase4OverseasPda if (!fixture?.username || !fixture?.password || fixture?.marker !== context.marker || pda?.marker !== context.marker || pda?.finalStatus !== 'COMPLETED') { blocker(context, '取得本轮客户账号与海外PDA库存前置', `merchantMarker=${fixture?.marker || 'missing'}, pdaMarker=${pda?.marker || 'missing'}, pdaStatus=${pda?.finalStatus || 'missing'}`) return null } const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.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) return nonBlank(auth?.overseasAccessToken) ? auth.overseasAccessToken : null } async function currentOrders(context, token) { const pda = context.createdResources.phase4OverseasPda const containerNos = [...new Set([...(pda.completedContainers || []), pda.containerNo].map(id).filter(Boolean))] const result = [] for (const containerNo of containerNos) { const page = await merchant(context, token, 'GET', `查询本轮完成柜${containerNo}的客户尾程订单`, query(ORDERS, { page: 1, size: 200, containerNo })) for (const row of records(page.data)) { if (id(row?.containerNo) !== containerNo || !id(row?.id)) continue const detail = await merchant(context, token, 'GET', `读取本轮尾程订单${id(row.id)}详情`, `${ORDERS}/${encodeURIComponent(id(row.id))}`) if (success(detail.response) && id(detail.data?.order?.id) === id(row.id)) { result.push({ row: detail.data.order, cargo: Array.isArray(detail.data?.cargo) ? detail.data.cargo : [] }) } } } const ownedIds = new Set(result.map((value) => id(value.row?.id))) context.assert(MODULE, '客户Token只取得本轮PDA完成柜的自身尾程事实', null, result.length > 0 && (!pda.orderId || ownedIds.has(id(pda.orderId))), `containers=${containerNos.join(',') || 'missing'}, orders=${[...ownedIds].join(',') || 'missing'}`, OPTIONS) return result } function usableCargo(order) { return order.cargo.find((item) => nonBlank(item?.skuCode) && nonBlank(item?.productNameZh) && Number(item?.packageCount) > 0) } async function verifyRelabel(context, token, order) { const cargo = usableCargo(order) if (!cargo || !nonBlank(order.row?.overseasWarehouseId)) { blocker(context, '本轮贴标申请候选', `orderId=${id(order.row?.id) || 'missing'}, sku=${cargo?.skuCode || 'missing'}, warehouse=${order.row?.overseasWarehouseId || 'missing'}`) return false } const body = { customerOrderNo: order.row.orderNo, overseasWarehouseId: order.row.overseasWarehouseId, labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255), items: [{ skuCode: cargo.skuCode, productImageFileId: null, productImageFileName: null, productImageUrl: null, productNameZh: cargo.productNameZh, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker, relabelFileId: null, relabelFileName: null, relabelFileUrl: null, cartonMark: `CM-${String(context.marker).slice(-32)}`, fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`, }], } const created = await merchant(context, token, 'POST', '客户创建贴标申请', RELABEL, body) const relabelNo = id(created.data?.application?.relabelNo) context.assert(MODULE, '客户贴标申请进入合法待处理状态', created.response, Boolean(relabelNo) && created.data?.application?.status === 'PENDING_PROCESSING' && created.data?.application?.customerOrderNo === order.row.orderNo, `relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!relabelNo) return false const replay = await merchant(context, token, 'POST', '客户贴标申请幂等重放', RELABEL, body) context.assert(MODULE, '贴标重放复用同一申请', replay.response, id(replay.data?.application?.relabelNo) === relabelNo, trace(replay.response), OPTIONS) const hashConflict = await context.requestOverseas('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token) context.addStep(MODULE, '贴标同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) const page = await merchant(context, token, 'GET', '客户查询自身贴标申请列表', query(RELABEL, { page: 1, size: 20, relabelNo, customerId: 0 })) context.assert(MODULE, '伪造customerId不改变贴标客户范围', page.response, records(page.data).some((item) => id(item?.relabelNo) === relabelNo), trace(page.response), OPTIONS) const detail = await merchant(context, token, 'GET', '客户查询自身贴标申请详情', `${RELABEL}/${encodeURIComponent(relabelNo)}`) context.assert(MODULE, '贴标详情保留明细与创建历史', detail.response, id(detail.data?.application?.relabelNo) === relabelNo && detail.data?.application?.status === 'PENDING_PROCESSING' && Array.isArray(detail.data?.items) && detail.data.items.length === 1 && Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_PROCESSING'), `items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.history?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.relabelNo = relabelNo return true } async function verifyTransfer(context, token, orders) { const order = orders.find((value) => nonBlank(value.row?.inboundCode) && nonBlank(value.row?.fbaWarehouseCode) && nonBlank(value.row?.overseasWarehouseId) && usableCargo(value)) const cargo = order ? usableCargo(order) : null if (!order || !cargo) { blocker(context, '本轮仓储转运申请候选', `orders=${orders.length}, fbaOrders=${orders.filter((value) => nonBlank(value.row?.fbaWarehouseCode)).length}; 禁止借历史入仓单`) return false } const packages = 1 const volume = Number(cargo.volumeCbm) > 0 ? Number(cargo.volumeCbm) : 0.0001 const body = { inboundCode: order.row.inboundCode, sourceLastlegOrderIds: [id(order.row.id)], operatingWarehouseId: order.row.overseasWarehouseId, fbaWarehouseCode: order.row.fbaWarehouseCode, deliveryAddress: `${order.row.fbaWarehouseCode} ${context.marker}`.slice(0, 1000), packageCount: packages, volumeCbm: volume, deliveryMethod: 'TRUCK', estimatedArrivalAt: future(10), fbaShipmentNo: cargo.shipmentNo || `${context.marker}-TRANSFER-SHIP`, trackingNo: cargo.fbaTrackingNo || null, fbaBoxNos: cargo.shippingMark || null, remark: context.marker, needRelabel: false, idempotencyKey: `${context.marker}-CLIENT-TRANSFER`.slice(0, 255), items: [{ skuCode: cargo.skuCode, productNameZh: cargo.productNameZh, packageCount: packages, shippingMark: cargo.shippingMark || null }], } const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body) const transferNo = id(created.data?.transferNo) context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response, Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW' && created.data?.sourceOrders?.some((item) => id(item?.lastlegOrderId) === id(order.row.id)), `transferNo=${transferNo || 'missing'}, status=${created.data?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!transferNo) return false const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body) context.assert(MODULE, '转运重放复用同一申请', replay.response, id(replay.data?.transferNo) === transferNo, trace(replay.response), OPTIONS) const hashConflict = await context.requestOverseas('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token) context.addStep(MODULE, '转运同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) const page = await merchant(context, token, 'GET', '客户查询自身仓储转运列表', query(TRANSFER, { page: 1, size: 20, transferNo, customerId: 0 })) context.assert(MODULE, '伪造customerId不改变转运客户范围', page.response, records(page.data).some((item) => id(item?.transferNo) === transferNo), trace(page.response), OPTIONS) const detail = await merchant(context, token, 'GET', '客户查询自身仓储转运详情', `${TRANSFER}/${encodeURIComponent(transferNo)}`) context.assert(MODULE, '转运详情保持待审核且不产生出库履约事实', detail.response, id(detail.data?.transferNo) === transferNo && detail.data?.status === 'PENDING_REVIEW' && detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0, `status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.transferNo = transferNo return true } async function verifyDiscard(context, token, markerOrders) { const response = await context.requestOverseas('GET', `${DISCARD}/inventory-candidates`, undefined, {}, token) const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS) const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id))) const candidate = Array.isArray(candidates) ? candidates.find((value) => markerOrderIds.has(id(value?.orderId)) && Array.isArray(value?.skus) && value.skus.some((sku) => nonBlank(sku?.skuCode) && Number(sku?.availablePackages) > 0)) : null if (!candidate) { blocker(context, '本轮货物废弃库存候选', `candidateOrders=${Array.isArray(candidates) ? candidates.map((value) => id(value?.orderId)).join(',') : 'missing'}, markerOrders=${[...markerOrderIds].join(',') || 'missing'}; 禁止借历史一件代发库存`, response) return false } const sku = candidate.skus.find((value) => nonBlank(value?.skuCode) && Number(value?.availablePackages) > 0) const body = { items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1 }], idempotencyKey: `${context.marker}-CLIENT-DISCARD`.slice(0, 255), remark: context.marker, } const created = await merchant(context, token, 'POST', '客户创建货物废弃申请', DISCARD, body) const discardNo = id(created.data?.summary?.discardNo) context.assert(MODULE, '废弃申请只锁定库存并进入合法待审核状态', created.response, Boolean(discardNo) && created.data?.summary?.status === 'PENDING_REVIEW' && Number(created.data?.summary?.totalPackages) === 1, `discardNo=${discardNo || 'missing'}, status=${created.data?.summary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!discardNo) return false const replay = await merchant(context, token, 'POST', '客户货物废弃申请幂等重放', DISCARD, body) context.assert(MODULE, '废弃重放复用同一申请', replay.response, id(replay.data?.summary?.discardNo) === discardNo, trace(replay.response), OPTIONS) const page = await merchant(context, token, 'GET', '客户查询自身货物废弃列表', query(DISCARD, { page: 1, size: 20, discardNo, customerId: 0 })) context.assert(MODULE, '伪造customerId不改变废弃客户范围', page.response, records(page.data).some((item) => id(item?.discardNo) === discardNo), trace(page.response), OPTIONS) const detail = await merchant(context, token, 'GET', '客户查询自身货物废弃详情', `${DISCARD}/${encodeURIComponent(discardNo)}`) context.assert(MODULE, '废弃详情保留候选快照和创建历史且未审核扣减', detail.response, id(detail.data?.summary?.discardNo) === discardNo && detail.data?.summary?.status === 'PENDING_REVIEW' && Array.isArray(detail.data?.items) && detail.data.items.length === 1 && Array.isArray(detail.data?.histories) && detail.data.histories.some((item) => item?.toStatus === 'PENDING_REVIEW'), `items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.histories?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.discardNo = discardNo return true } export async function run(context) { context.createdResources.phase4OverseasClientServices = { marker: context.marker, relabelNo: null, transferNo: null, discardNo: null, finalStatuses: { relabel: null, transfer: null, discard: null }, cleanup: 'Merchant applications remain in their legal initial review/processing states; no outbound, dispatch, PDA or delivery action is invoked', } const token = await loginMerchant(context) if (!token) return for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) { const denied = await context.requestOverseas('GET', query(path, { page: 1, size: 1 })) context.addStep(MODULE, `平台海外Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS) } const orders = await currentOrders(context, token) if (orders.length === 0) { blocker(context, '取得本轮客户尾程与库存事实', '客户Token未查到本轮phase4 PDA完成柜,禁止借历史订单') return } const relabelOrder = orders.find((value) => usableCargo(value) && nonBlank(value.row?.overseasWarehouseId)) if (!relabelOrder) { blocker(context, '本轮贴标订单明细', `orders=${orders.length}; 缺少真实SKU/品名/箱数或实际海外仓`) return } const relabelDone = await verifyRelabel(context, token, relabelOrder) const transferDone = await verifyTransfer(context, token, orders) const discardDone = await verifyDiscard(context, token, orders) context.createdResources.phase4OverseasClientServices.finalStatuses = { relabel: relabelDone ? 'PENDING_PROCESSING' : null, transfer: transferDone ? 'PENDING_REVIEW' : null, discard: discardDone ? 'PENDING_REVIEW' : null, } }