481 lines
32 KiB
JavaScript
481 lines
32 KiB
JavaScript
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
|
|
import { allocateWarehouseTransfer } from './warehouse-transfer-allocation.mjs'
|
|
import { outboundWarehouseTransfer } from './warehouse-transfer-outbound.mjs'
|
|
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.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'
|
|
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.request(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?.accessToken) && nonBlank(auth?.refreshToken),
|
|
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
|
|
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : 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 inventory = await merchant(context, token, 'GET', '客户查询贴标权威库存候选',
|
|
query(`${RELABEL}/inventory-candidates`, {
|
|
operationWarehouseId: order.row.overseasWarehouseId,
|
|
inboundCode: order.row.inboundCode,
|
|
}))
|
|
const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) =>
|
|
id(item?.inventoryBalanceId) && id(item?.inboundOrderId)
|
|
&& 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,
|
|
labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006',
|
|
chargeAmount: 0.01,
|
|
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
|
remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255),
|
|
items: [{
|
|
sourceInventoryBalanceId: id(source.inventoryBalanceId),
|
|
sourceInventoryPositionId: id(position.positionId),
|
|
sourceInboundOrderId: id(source.inboundOrderId),
|
|
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,
|
|
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: source.destinationFbaWarehouseCode ? `FBA-${String(context.marker).slice(-32)}` : null,
|
|
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
|
}],
|
|
}
|
|
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_REVIEW'
|
|
&& 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.request('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_REVIEW'
|
|
&& Array.isArray(detail.data?.items) && detail.data.items.length === 1
|
|
&& 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 })
|
|
if (!context.options?.modules?.includes('phase5-overseas-fulfillment')) context.addCoverageGap(MODULE, 'client-relabel-delivery', '客户原贴标申请的排车至交付',
|
|
'当前定向范围仅到客户确认;完整默认链继续使用本申请targetPalletIds排车并签收,不能借其他换标单结果',
|
|
'fixture', { 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))
|
|
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: true,
|
|
labelDeadline: future(7), relabelContactName: '客户贴标验收联系人', relabelContactPhone: '13800000006',
|
|
relabelChargeAmount: 0.01, relabelDeliveryMethod: 'TRUCK',
|
|
idempotencyKey: `${context.marker}-CLIENT-TRANSFER`.slice(0, 255),
|
|
items: [{ skuCode: cargo.skuCode, productNameZh: cargo.productNameZh,
|
|
packageCount: packages, shippingMark: cargo.shippingMark || null }],
|
|
relabelItems: [{ operationType: 'CARTON_AND_SKU', customerCartonMarks: [cargo.shippingMark || `CM-${context.marker}`],
|
|
originalSku: cargo.skuCode, newSku: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1,
|
|
fbaCartonMark: `FBA-${context.marker}`.slice(0, 255), destinationWarehouseCode: order.row.fbaWarehouseCode,
|
|
remark: context.marker }],
|
|
}
|
|
const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body)
|
|
const transferNo = id(created.data?.transferNo)
|
|
if (transferNo && created.data?.status === 'DRAFT') {
|
|
created.data = await submitClientTransfer(context, token, 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.request('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
|
|
if (!await verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)) return false
|
|
const transferProof = context.createdResources.phase4OverseasClientServices.transferFulfillment = {
|
|
marker: context.marker, customerId: id(order.row.customerId), warehouseId: id(order.row.overseasWarehouseId),
|
|
transferNo, inboundCode: order.row.inboundCode, skuCode: cargo.skuCode, status: 'APPROVED',
|
|
}
|
|
await allocateWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
|
|
const relabelProof = transferProof.relabelFulfillment = {
|
|
marker: context.marker, relabelNo: transferProof.relabelNo, customerId: transferProof.customerId,
|
|
warehouseId: transferProof.warehouseId, sourceOrderNo: transferNo,
|
|
sourcePositionId: transferProof.sourcePositionId, sourcePalletNo: transferProof.sourcePalletNo,
|
|
status: 'PENDING_REVIEW',
|
|
}
|
|
const access = await resolvePickerAccess(context)
|
|
if (!access) throw new Error('转运换标后续缺少有效PDA角色')
|
|
const picker = await createPicker(context, 96, transferProof.warehouseId, access)
|
|
try {
|
|
if (!picker?.token) throw new Error('转运换标未取得本仓PDA会话')
|
|
await verifyClientRelabelFulfillment(context, { proof: relabelProof, transferProof, merchantToken: token, pdaToken: picker.token })
|
|
} finally {
|
|
if (picker?.membershipId) await disablePicker(context, picker, 96)
|
|
}
|
|
await outboundWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
|
|
await receiveWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
|
|
return true
|
|
}
|
|
|
|
export async function verifyDiscard(context, token, markerOrders) {
|
|
const response = await context.request('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 proof = context.createdResources.phase4OverseasClientServices.discardApproval = {
|
|
sourceOrderId: id(candidate.orderId), customerId: id(context.context.merchantQualificationFixture.tenantId),
|
|
warehouseId: candidate.overseasWarehouseId, inboundCode: candidate.inboundCode,
|
|
skuCode: sku.skuCode, snapshots: {}, status: 'RUNNING',
|
|
contract: 'Approval retains the existing reservation without deducting stock. Actual PDA confirmation deducts the confirmed quantity; completed confirmation replay must not write again. Review has no idempotencyKey.',
|
|
}
|
|
const before = await discardSnapshot(context, token, proof)
|
|
proof.snapshots.before = before
|
|
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
|
|
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
|
|
}
|
|
|
|
|
|
function stable(value) {
|
|
if (Array.isArray(value)) return JSON.stringify(value.map(stable).sort())
|
|
if (value && typeof value === 'object') return JSON.stringify(Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])])))
|
|
return value
|
|
}
|
|
function discardRequire(context, name, passed, evidence = '') {
|
|
context.assert(MODULE, name, null, Boolean(passed), evidence, OPTIONS)
|
|
if (!passed) throw new Error(name)
|
|
}
|
|
async function discardCommand(context, token, method, name, path, body) {
|
|
const result = await merchant(context, token, method, name, path, body)
|
|
if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`)
|
|
return result.data
|
|
}
|
|
async function discardSnapshot(context, token, proof, discardNo) {
|
|
const all = async (kind) => {
|
|
const rows = []
|
|
let total
|
|
for (let page = 1; page <= 100; page++) {
|
|
const data = await discardCommand(context, context.token, 'GET', `废弃核对完整${kind}`,
|
|
query(`/api/platform/v1/last-leg/inventory/${kind}`, { customerId: proof.customerId,
|
|
overseasWarehouseId: proof.warehouseId, page, size: 200 }))
|
|
discardRequire(context, '废弃库存分页作用域与总数稳定', Array.isArray(data?.records)
|
|
&& Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0
|
|
&& (total === undefined || total === Number(data.total))
|
|
&& data.records.every(row => id(row.customerId) === proof.customerId && row.overseasWarehouseId === proof.warehouseId))
|
|
total = Number(data.total); rows.push(...data.records)
|
|
if (rows.length === total) return rows
|
|
discardRequire(context, '废弃库存分页不能截断', rows.length < total && data.records.length > 0)
|
|
}
|
|
throw new Error('废弃库存分页超过上限')
|
|
}
|
|
const balances = await all('balances'), ledger = await all('ledger')
|
|
const candidates = await discardCommand(context, token, 'GET', '废弃回查本轮客户真实候选', `${DISCARD}/inventory-candidates`)
|
|
const source = candidates.find(row => id(row.orderId) === proof.sourceOrderId)
|
|
const detail = discardNo ? await discardCommand(context, token, 'GET', '废弃回查申请真实状态', `${DISCARD}/${encodeURIComponent(discardNo)}`) : null
|
|
return { balances, ledger, source: source || null, detail }
|
|
}
|
|
export async function verifyDiscardApproval(context, token, body, discardNo, proof) {
|
|
const require = (name, passed) => discardRequire(context, name, passed, `discardNo=${discardNo}`)
|
|
const sameStock = (a,b) => stable(a.balances) === stable(b.balances) && stable(a.ledger) === stable(b.ledger)
|
|
const base = proof.snapshots.before
|
|
require('废弃来源属于本轮客户及实际收货仓', base.source?.overseasWarehouseId === proof.warehouseId
|
|
&& base.source?.inboundCode === proof.inboundCode && Number(base.source.availablePackages) >= 1)
|
|
let pending = await discardSnapshot(context, token, proof, discardNo)
|
|
proof.snapshots.pending = pending
|
|
require('申请仅锁定一箱且余额流水未扣减', sameStock(base,pending)
|
|
&& Number(pending.source?.availablePackages || 0) === Number(base.source.availablePackages) - 1)
|
|
const conflictBody = { ...body, remark: `${body.remark}-DIFFERENT` }
|
|
const response = await context.request('POST', DISCARD, conflictBody, token, {})
|
|
context.addStep(MODULE, '废弃创建同键异载荷拒绝', response, conflict(response), trace(response), OPTIONS)
|
|
require('废弃创建异载荷有明确冲突诊断', conflict(response) && nonBlank(response.requestId) && nonBlank(response.traceId))
|
|
require('废弃创建异载荷拒绝后事实不变', stable(await discardSnapshot(context,token,proof,discardNo)) === stable(pending))
|
|
const root = '/api/platform/v1/last-leg/cargo-discards'
|
|
const review = (number,decision) => discardCommand(context, context.token, 'POST', `废弃审批${decision}`,
|
|
query(`${root}/${encodeURIComponent(number)}/review`, { customerId: proof.customerId }), { decision, remark: context.marker })
|
|
await review(discardNo,'REJECT')
|
|
const rejected = await discardSnapshot(context,token,proof,discardNo); proof.snapshots.rejected = rejected
|
|
require('废弃拒绝释放原箱且全部库存数量流水不变', rejected.detail?.summary?.status === 'REJECTED'
|
|
&& sameStock(base,rejected) && stable(base.source) === stable(rejected.source))
|
|
await review(discardNo,'REJECT')
|
|
require('废弃重复拒绝不重复释放', stable(await discardSnapshot(context,token,proof,discardNo)) === stable(rejected))
|
|
const approvedBody = { ...body, idempotencyKey: `${body.idempotencyKey}-APPROVE` }
|
|
const created = await discardCommand(context,token,'POST','创建独立待批准废弃申请',DISCARD,approvedBody)
|
|
const approvedNo = id(created?.summary?.discardNo); require('批准对照使用本轮新申请', nonBlank(approvedNo) && approvedNo !== discardNo)
|
|
proof.approvedNo = approvedNo
|
|
await review(approvedNo,'APPROVE')
|
|
const approved = await discardSnapshot(context,token,proof,approvedNo); proof.snapshots.approved = approved
|
|
require('批准只保留锁箱且库存数量流水不变', approved.detail?.summary?.status === 'APPROVED'
|
|
&& sameStock(base, approved)
|
|
&& Number(approved.source?.availablePackages || 0) === Number(base.source.availablePackages) - 1)
|
|
await review(approvedNo,'APPROVE')
|
|
require('重复批准不会提前扣库存或新增历史', stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved))
|
|
const reverse = await context.request('POST',query(`${root}/${encodeURIComponent(approvedNo)}/review`,{customerId:proof.customerId}),{decision:'REJECT',remark:context.marker},context.token,{})
|
|
require('批准后反向审核拒绝且事实不变', conflict(reverse) && nonBlank(reverse.requestId) && nonBlank(reverse.traceId)
|
|
&& stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved))
|
|
proof.status = 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING'
|
|
proof.remainingEvidence = 'Approval reservation only. Actual PDA confirm, completed-confirm recovery/replay and independent ll_dropship_carton/ll_dropship_sku_inventory/ll_dropship_carton_ledger evidence remain required; public ledger is not carton ledger.'
|
|
|
|
}
|
|
|
|
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: '保留原贴标申请、转运分配换标出入库及废弃完成证据;不自动删除业务数据;贴标排车签收由后续模块执行',
|
|
}
|
|
|
|
for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) {
|
|
const denied = await context.request('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 ? 'READY_FOR_OUTBOUND' : null,
|
|
transfer: transferDone ? context.createdResources.phase4OverseasClientServices.transferFulfillment?.status ?? null : null,
|
|
discard: discardDone ? 'COMPLETED' : null,
|
|
}
|
|
}
|