This commit is contained in:
2026-10-22 14:37:59 +08:00
parent de96e47d74
commit 760bf8daa1
49 changed files with 2450 additions and 117 deletions
@@ -1,4 +1,8 @@
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
import { fundRelabelWallet } from './phase4-overseas-pda-operations.mjs'
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
const MODULE = 'phase4-overseas-client-services'
const SCENARIO = 'phase4-overseas-client-services-lifecycle'
@@ -51,9 +55,9 @@ async function loginMerchant(context) {
loginId: fixture.username, password: fixture.password,
}, '')
const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS)
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
return nonBlank(auth?.accessToken) ? auth.accessToken : null
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
}
async function currentOrders(context, token) {
@@ -98,13 +102,17 @@ async function verifyRelabel(context, token, order) {
}))
const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) =>
id(item?.inventoryBalanceId) && id(item?.inboundOrderId)
&& item?.skuCode === cargo.skuCode
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0))
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0)
&& item?.skuCode === cargo.skuCode && item.inboundCode === order.row.inboundCode
&& id(item.overseasWarehouseId) === id(order.row.overseasWarehouseId)
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0
&& nonBlank(position.palletNo) && position.palletNo.toUpperCase() !== 'UNBOUND'))
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0
&& nonBlank(item.palletNo) && item.palletNo.toUpperCase() !== 'UNBOUND')
context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', inventory.response,
Boolean(source && position),
`balanceId=${id(source?.inventoryBalanceId) || 'missing'}, positionId=${id(position?.positionId) || 'missing'}, inboundOrderId=${id(source?.inboundOrderId) || 'missing'}`, OPTIONS)
if (!source || !position) return false
if (!await fundRelabelWallet(context, id(context.context.merchantQualificationFixture.tenantId))) return false
const body = {
customerOrderNo: order.row.orderNo,
operationWarehouseId: order.row.overseasWarehouseId,
@@ -120,9 +128,11 @@ async function verifyRelabel(context, token, order) {
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,
operationType: source.destinationFbaWarehouseCode ? 'CARTON_AND_SKU' : 'SKU_LABEL',
skuItems: [{ skuCode: cargo.skuCode, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1, remark: context.marker }],
relabelFileId: null, relabelFileName: null, relabelFileUrl: null,
cartonMark: `CM-${String(context.marker).slice(-32)}`,
fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`,
fbaCartonMark: source.destinationFbaWarehouseCode ? `FBA-${String(context.marker).slice(-32)}` : null,
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
}],
}
@@ -150,9 +160,52 @@ async function verifyRelabel(context, token, order) {
&& Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_REVIEW'),
`items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.history?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
context.createdResources.phase4OverseasClientServices.relabelNo = relabelNo
const proof = context.createdResources.phase4OverseasClientServices.relabelFulfillment = {
marker: context.marker, relabelNo, customerId: id(context.context.merchantQualificationFixture.tenantId),
sourceOrderNo: order.row.orderNo, warehouseId: id(order.row.overseasWarehouseId),
sourcePositionId: id(position.positionId), sourcePalletNo: position.palletNo, status: 'PENDING_REVIEW',
}
const access = await resolvePickerAccess(context)
if (!access) throw new Error('贴标后续缺少有效PDA角色')
const picker = await createPicker(context, 94, proof.warehouseId, access)
try {
if (!picker?.token) throw new Error('贴标后续未取得本仓PDA会话')
await verifyClientRelabelFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token })
context.addCoverageGap(MODULE, 'client-relabel-delivery', '客户原贴标申请的排车至交付',
'原申请已完成PDA贴标和客户确认,后续必须使用本申请targetPalletIds接履约,不能借其他换标单结果',
'not-implemented', { blocking: false })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 94)
}
return true
}
// 提交只发送一次,未知结果保留单号并停止,不能用详情回查掩盖失败。
export async function submitClientTransfer(context, token, transferNo) {
if (!nonBlank(transferNo)) throw new Error('仓储转运提交缺少单号')
if (context.createdResources.phase4OverseasClientServices.transferSubmission) throw new Error('本轮仓储转运已有提交证据,禁止重复提交')
const proof = { transferNo, marker: context.marker, status: 'SUBMITTING' }
context.createdResources.phase4OverseasClientServices.transferSubmission = proof
try {
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
Object.assign(proof, { http: response?.http, code: codeOf(response), requestId: response?.requestId, traceId: response?.traceId })
const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS)
if (!success(response)) {
proof.status = !response?.http || response.http >= 500 || !response.body ? 'UNKNOWN_RESULT' : 'REJECTED'
throw new Error(`仓储转运提交未成功,停止后续写入: ${transferNo}; ${trace(response)}`)
}
if (id(data?.transferNo) !== transferNo || data?.status !== 'PENDING_REVIEW') {
proof.status = 'UNEXPECTED_RESULT'
throw new Error(`仓储转运提交返回单号或状态不符: ${transferNo}`)
}
proof.status = 'PENDING_REVIEW'
return data
} catch (error) {
if (proof.status === 'SUBMITTING') proof.status = 'UNKNOWN_RESULT'
throw error
}
}
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))
@@ -184,26 +237,7 @@ async function verifyTransfer(context, token, orders) {
const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body)
const transferNo = id(created.data?.transferNo)
if (transferNo && created.data?.status === 'DRAFT') {
// The overseas gateway occasionally drops a connection while the submit
// request is still being processed. Retry transport failures only, then
// reconcile from the authoritative detail before asserting the state.
let submitted = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
if (response?.http === 0 && attempt < 3) {
await new Promise(resolve => setTimeout(resolve, 800 * attempt))
continue
}
const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS)
submitted = { response, data }
break
}
if (submitted?.data) created.data = submitted.data
if (created.data?.status !== 'PENDING_REVIEW') {
const reconciled = await merchant(context, token, 'GET', '提交后回查仓储转运申请',
`${TRANSFER}/${encodeURIComponent(transferNo)}`)
if (reconciled.data) created.data = reconciled.data
}
created.data = await submitClientTransfer(context, token, transferNo)
}
context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response,
Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW'
@@ -275,7 +309,21 @@ export async function verifyDiscard(context, token, markerOrders) {
&& 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
try { await verifyDiscardApproval(context, token, body, discardNo, proof) }
try {
await verifyDiscardApproval(context, token, body, discardNo, proof)
const access = await resolvePickerAccess(context)
if (!access) throw new Error('废弃执行缺少有效PDA角色')
const picker = await createPicker(context, 93, proof.warehouseId, access)
try {
if (!picker?.token) throw new Error('废弃执行未取得本仓PDA会话')
await verifyCargoDiscardFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token,
snapshot: () => discardSnapshot(context, token, proof, proof.approvedNo) })
context.addCoverageGap(MODULE, 'discard-carton-storage-evidence', '废弃底层箱、SKU和箱级流水独立取证',
'已接入PDA执行、客户完成、公共库存总账和终态重放;未执行底层独立取证', 'prerequisite', { blocking: false })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 93)
}
}
catch (error) { proof.status = 'FAILED'; proof.failure = error.message; throw error }
return true
}
@@ -363,13 +411,22 @@ export async function verifyDiscardApproval(context, token, body, discardNo, pro
}
export async function run(context) {
const auth = await loginMerchant(context)
if (!auth) return
try {
return await runWithSession(context, auth.accessToken)
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
context.apiSuccess(MODULE, '退出本轮客户服务会话', logout, trace(logout), OPTIONS)
}
}
async function runWithSession(context, token) {
context.createdResources.phase4OverseasClientServices = {
marker: context.marker, relabelNo: null, transferNo: null, discardNo: null,
finalStatuses: { relabel: null, transfer: null, discard: null },
cleanup: 'Relabel remains pending; transfer review fixtures retained; discard rejection and approval lock checked; actual stock deduction requires separate PDA confirmation; no outbound, dispatch or delivery action is invoked',
cleanup: 'Relabel ready for dispatch; transfer review fixtures retained; discard PDA execution and customer confirmation retained; no dispatch 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.request('GET', query(path, { page: 1, size: 1 }))
@@ -390,8 +447,8 @@ export async function run(context) {
const transferDone = await verifyTransfer(context, token, orders)
const discardDone = await verifyDiscard(context, token, orders)
context.createdResources.phase4OverseasClientServices.finalStatuses = {
relabel: relabelDone ? 'PENDING_REVIEW' : null,
relabel: relabelDone ? 'READY_FOR_OUTBOUND' : null,
transfer: transferDone ? 'APPROVED' : null,
discard: discardDone ? 'APPROVED' : null,
discard: discardDone ? 'COMPLETED' : null,
}
}