389 lines
27 KiB
JavaScript
389 lines
27 KiB
JavaScript
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { verifyProcurementSample } from './procurement-sample-fulfillment.mjs'
|
|
import { normalizeFixedCustomerId } from './merchant-qualification-fixture.mjs'
|
|
|
|
const MODULE = 'phase4-overseas-wallet-and-pallet-cost'
|
|
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
|
const PROCUREMENT = '/api/platform/v1/last-leg/overseas-procurements'
|
|
const RELABEL = '/api/platform/v1/last-leg/relabel-applications'
|
|
const FINANCE = '/api/platform/finance/v1'
|
|
const CLIENT_FINANCE = '/api/client/finance/v1'
|
|
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
|
|
|
const id = (value) => value == null ? '' : String(value)
|
|
const money = (value) => Number(Number(value || 0).toFixed(2))
|
|
const success = (response) => response?.http === 200 && String(response?.body?.code) === '200'
|
|
const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : []
|
|
const trace = (response) => `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
|
const query = (path, params) => {
|
|
const search = new URLSearchParams()
|
|
for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
|
|
return `${path}?${search}`
|
|
}
|
|
|
|
export function beijingDate(now = new Date()) {
|
|
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
|
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
}).formatToParts(now).map((part) => [part.type, part.value]))
|
|
return `${parts.year}${parts.month}${parts.day}`
|
|
}
|
|
|
|
export function matchesProcurementNo(orderNo, now = new Date()) {
|
|
return new RegExp(`^CG${beijingDate(now)}\\d{4}$`).test(String(orderNo || ''))
|
|
}
|
|
|
|
async function poll(action, predicate, attempts = 60, intervalMs = 1_000) {
|
|
let result = null
|
|
for (let index = 0; index < attempts; index += 1) {
|
|
result = await action()
|
|
if (predicate(result)) return { result, attempts: index + 1 }
|
|
if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
}
|
|
return { result, attempts }
|
|
}
|
|
|
|
function assert(context, name, response, passed, evidence = '') {
|
|
context.assert(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS)
|
|
}
|
|
|
|
async function merchantLogin(context, fixture) {
|
|
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
|
const auth = success(response) ? response.body.data : null
|
|
assert(context, '客户账号取得统一会话与退出凭据', response,
|
|
Boolean(auth?.accessToken) && Boolean(auth?.refreshToken),
|
|
`customerId=${fixture.tenantId}, session=${Boolean(auth?.accessToken)}, refresh=${Boolean(auth?.refreshToken)}`)
|
|
return auth
|
|
}
|
|
|
|
async function platformAccount(context, customerId) {
|
|
const response = await context.request('GET', query(`${FINANCE}/accounts`, { page: 1, pageSize: 20, customerId }))
|
|
const account = success(response) ? rows(response.body.data).find((item) => id(item.customerId) === id(customerId)) : null
|
|
return { response, account }
|
|
}
|
|
|
|
async function prepareWallet(context, customerId) {
|
|
const before = await platformAccount(context, customerId)
|
|
assert(context, '平台客户账户返回余额和授信字段', before.response,
|
|
before.account && ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit', 'accountVersion']
|
|
.every((field) => Number.isFinite(Number(before.account[field]))), `customerId=${customerId}`)
|
|
if (!before.account) return null
|
|
|
|
const creditLimit = 2.00
|
|
const creditExpiresOn = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
|
const credit = await context.request('PUT', `${FINANCE}/accounts/${encodeURIComponent(customerId)}/credit-limit`,
|
|
{ creditLimit, creditExpiresOn, remark: context.marker,
|
|
expectedAccountVersion: Number(before.account.accountVersion) }, context.token,
|
|
{ 'Idempotency-Key': `${context.marker}-wallet-credit-limit`.slice(0, 128) })
|
|
assert(context, '授信额度可通过正式接口初始化', credit,
|
|
success(credit) && money(credit.body.data?.creditLimitAfter) === creditLimit,
|
|
`creditLimit=${credit.body?.data?.creditLimitAfter ?? 'missing'}`)
|
|
if (!success(credit)) return null
|
|
|
|
const credited = await platformAccount(context, customerId)
|
|
assert(context, '授信初始化后账户版本和可用授信已更新', credited.response,
|
|
credited.account && money(credited.account.creditLimit) === creditLimit
|
|
&& Number(credited.account.accountVersion) > Number(before.account.accountVersion),
|
|
`version=${credited.account?.accountVersion ?? 'missing'}, availableCredit=${credited.account?.availableCredit ?? 'missing'}`)
|
|
if (!credited.account) return null
|
|
|
|
const rechargeAmount = 10.00
|
|
const recharge = await context.request('POST', `${FINANCE}/accounts/${encodeURIComponent(customerId)}/adjustments`, {
|
|
type: 'RECHARGE', amount: rechargeAmount, occurredDate: new Date().toISOString().slice(0, 10),
|
|
paymentMethod: 'BANK_TRANSFER', referenceNo: `${context.marker}-AUTO`, remark: context.marker,
|
|
expectedAccountVersion: Number(credited.account.accountVersion),
|
|
}, context.token, { 'Idempotency-Key': `${context.marker}-wallet-recharge`.slice(0, 128) })
|
|
assert(context, '平台人工充值立即进入实际余额', recharge,
|
|
success(recharge) && money(recharge.body.data?.actualBalanceAfter) === money(Number(before.account.actualBalance) + rechargeAmount),
|
|
`before=${before.account.actualBalance}, after=${recharge.body?.data?.actualBalanceAfter ?? 'missing'}`)
|
|
return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null
|
|
}
|
|
|
|
async function procurementFlow(context, fixture, token, warehouseId) {
|
|
if (!/^[A-Za-z0-9_-]{1,80}$/.test(context.marker)) throw new Error('采购验收批次不适合作为独立证据标识')
|
|
const directory = new URL('../../reports/procurement-wallet-checkpoints/', import.meta.url)
|
|
await mkdir(directory, { recursive: true })
|
|
const output = new URL(`${context.marker}.json`, directory)
|
|
const procurementContext = Object.create(context)
|
|
let initialized = false
|
|
procurementContext.checkpoint = async () => {
|
|
const proof = context.createdResources.procurementSample || { marker: context.marker, status: 'PREPARING' }
|
|
await writeFile(output, JSON.stringify(proof, null, 2) + '\n', { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
|
initialized = true
|
|
}
|
|
await procurementContext.checkpoint()
|
|
const customerId = id(fixture.tenantId)
|
|
const upload = async (orderNo, category) => {
|
|
const proof = context.createdResources.procurementSample
|
|
const entry = { category, orderNo, status: 'SENDING' }
|
|
proof.uploads ??= []; proof.uploads.push(entry); await procurementContext.checkpoint()
|
|
let response
|
|
try {
|
|
response = await context.requestMultipart('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
|
|
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
|
|
} catch (error) { entry.status = 'UNKNOWN_RESULT'; await procurementContext.checkpoint(); throw error }
|
|
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http, code: response?.body?.code, fileId: response?.body?.data?.fileId, requestId: response?.requestId, traceId: response?.traceId })
|
|
await procurementContext.checkpoint()
|
|
assert(context, `采购受控上传${category}`, response, success(response) && Boolean(response.body.data?.fileId))
|
|
if (!success(response) || !response.body.data?.fileId) throw new Error('采购凭证上传未确认成功,不自动重试')
|
|
return response.body.data
|
|
}
|
|
try {
|
|
const proof = await verifyProcurementSample(procurementContext, { marker: context.marker, customerId,
|
|
customerName: fixture.tenantName, customerToken: token, warehouseId, upload,
|
|
readWallet: async () => { const result = await platformAccount(context, customerId); if (!success(result.response) || !result.account) throw new Error('本轮客户钱包查询失败'); return result.account } })
|
|
assert(context, '采购正常全链路及资金完成', null, proof.status === 'PASSED', `orderNo=${proof.orderNo}`)
|
|
assert(context, '采购单号为CG加北京时间日期和流水', null,
|
|
matchesProcurementNo(proof.orderNo), `orderNo=${proof.orderNo}`)
|
|
return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny }
|
|
} finally {
|
|
for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed)
|
|
}
|
|
}
|
|
|
|
function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
|
|
const value = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
|
return {
|
|
customerOrderNo: `${context.marker}-${suffix}`.slice(0, 128), operationWarehouseId: id(warehouseId),
|
|
labelDeadline: new Date(Date.now() + 86_400_000).toISOString().slice(0, 19),
|
|
contactName: '全量验收联系人', contactPhone: '+1-213-555-0199', chargeAmount,
|
|
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
|
remark: context.marker, idempotencyKey: `${context.marker}-${suffix}`.slice(0, 255),
|
|
items: [{ sourceInventoryBalanceId: id(source.inventoryBalanceId),
|
|
sourceInventoryPositionId: id(source.position.positionId), sourceInboundOrderId: id(source.inboundOrderId),
|
|
skuCode: source.skuCode || `OLD-${value}-${suffix}`.slice(0, 128),
|
|
productNameZh: source.productNameZh || `${context.marker} 换标商品`,
|
|
totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `SHIP-${value}-${suffix}`.slice(0, 128),
|
|
newSkuCode: `NEW-${value}-${suffix}`.slice(0, 128), labelRequirement: context.marker,
|
|
cartonMark: `CM-${value}-${suffix}`.slice(0, 255),
|
|
fbaCartonMark: `FBA-${value}-${suffix}`.slice(0, 255),
|
|
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode }],
|
|
}
|
|
}
|
|
|
|
async function relabelFlow(context, token, warehouseId, suffix, terminalStatus) {
|
|
const candidatesResponse = await context.request('GET', query(
|
|
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }), undefined, token, {})
|
|
const candidates = success(candidatesResponse) && Array.isArray(candidatesResponse.body?.data)
|
|
? candidatesResponse.body.data : []
|
|
const currentPda = context.createdResources?.phase4OverseasPda
|
|
const protectedPalletIds = new Set([
|
|
...(Array.isArray(currentPda?.palletIds) ? currentPda.palletIds : []),
|
|
...Object.values(currentPda?.generatedPalletsByInbound || {})
|
|
.flatMap((items) => Array.isArray(items) ? items.map((item) => id(item?.palletId)) : []),
|
|
].filter(Boolean))
|
|
const producerFixtures = context.createdResources?.phase4InboundProducerFixtures
|
|
const protectedInboundCodes = new Set((producerFixtures?.containers || [])
|
|
.map((item) => id(item?.inboundCode)).filter(Boolean))
|
|
const protectedInboundIds = new Set((producerFixtures?.containers || [])
|
|
.flatMap((item) => [item?.inboundId, item?.sourceInboundId, item?.inboundOrderId].map(id))
|
|
.filter(Boolean))
|
|
const isProtected = (candidate) => {
|
|
if (protectedInboundIds.has(id(candidate?.inboundOrderId))
|
|
|| protectedInboundCodes.has(id(candidate?.inboundCode))) return true
|
|
return (Array.isArray(candidate?.positions) ? candidate.positions : []).some((position) =>
|
|
protectedPalletIds.has(id(position?.palletId))
|
|
|| protectedPalletIds.has(id(position?.pallet?.id))
|
|
|| protectedPalletIds.has(id(position?.pallet?.palletId))
|
|
|| [...protectedPalletIds].includes(id(position?.sourcePalletId)))
|
|
}
|
|
const source = candidates.find((candidate) => id(candidate.inboundOrderId)
|
|
&& id(candidate.inventoryBalanceId)
|
|
&& id(candidate.destinationFbaWarehouseCode)
|
|
&& !isProtected(candidate)
|
|
&& candidate.positions?.some((position) => id(position.positionId) && Number(position.availablePackages) > 0))
|
|
if (source) source.position = source.positions.find((position) => id(position.positionId)
|
|
&& Number(position.availablePackages) > 0)
|
|
assert(context, `${suffix}换标读取当前客户权威库存候选`, candidatesResponse,
|
|
Boolean(source?.position), `warehouseId=${warehouseId}, candidates=${candidates.length}`)
|
|
if (!source?.position) return null
|
|
const payload = relabelPayload(context, warehouseId, source, suffix, 0.01)
|
|
const created = await context.request('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, token, {})
|
|
const relabelNo = created?.body?.data?.application?.relabelNo
|
|
assert(context, `${suffix}换标单创建并携带收费金额`, created,
|
|
success(created) && Boolean(relabelNo) && money(created.body.data?.application?.chargeAmount) === 0.01,
|
|
`relabelNo=${relabelNo || 'missing'}, charge=${created.body?.data?.application?.chargeAmount ?? 'missing'}`)
|
|
if (!success(created) || !relabelNo) return null
|
|
|
|
const detailPath = `${RELABEL}/${encodeURIComponent(relabelNo)}`
|
|
const frozen = await poll(async () => context.request('GET', detailPath),
|
|
(response) => success(response) && response.body.data?.application?.walletStatus === 'FROZEN')
|
|
assert(context, `${suffix}换标费用跨区冻结到账`, frozen.result,
|
|
frozen.result?.body?.data?.application?.walletStatus === 'FROZEN',
|
|
`relabelNo=${relabelNo}, pollRequests=${frozen.attempts}, walletStatus=${frozen.result?.body?.data?.application?.walletStatus || 'missing'}`)
|
|
if (frozen.result?.body?.data?.application?.walletStatus !== 'FROZEN') return null
|
|
|
|
const reviewStatus = terminalStatus === 'COMPLETED' ? 'PENDING_PROCESSING' : 'CANCELLED'
|
|
const review = await context.request('PUT', query(
|
|
`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.body.data.application.lastlegOrderId))}/relabel-review`,
|
|
{ customerId: id(context.context.merchantQualificationFixture.tenantId) }), { status: reviewStatus, remark: context.marker })
|
|
assert(context, `${suffix}尾程订单审核直接贴标申请`, review,
|
|
success(review) && review.body.data?.application?.status === reviewStatus)
|
|
if (!success(review)) return null
|
|
|
|
if (terminalStatus === 'COMPLETED') {
|
|
const processing = await context.request('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker })
|
|
assert(context, '换标单冻结后才允许进入处理中', processing,
|
|
success(processing) && processing.body.data?.application?.status === 'PROCESSING')
|
|
const confirmPending = await context.request('PUT', `${detailPath}/status`, { status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker })
|
|
assert(context, '换标处理完成后进入客户确认', confirmPending,
|
|
success(confirmPending) && confirmPending.body.data?.application?.status === 'CUSTOMER_CONFIRM_PENDING')
|
|
const readyForOutbound = await context.request('PUT', `${detailPath}/status`, { status: 'READY_FOR_OUTBOUND', remark: context.marker })
|
|
assert(context, '换标客户确认后进入待出库', readyForOutbound,
|
|
success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND')
|
|
}
|
|
const terminal = terminalStatus === 'CANCELLED' ? review : await context.request('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker })
|
|
assert(context, `${suffix}换标单进入${terminalStatus}`, terminal,
|
|
success(terminal) && terminal.body.data?.application?.status === terminalStatus)
|
|
const walletTerminal = terminalStatus === 'COMPLETED' ? 'SETTLED' : 'RELEASED'
|
|
const wallet = await poll(async () => context.request('GET', detailPath),
|
|
(response) => success(response) && response.body.data?.application?.walletStatus === walletTerminal)
|
|
assert(context, `${suffix}换标费用跨区${walletTerminal}`, wallet.result,
|
|
wallet.result?.body?.data?.application?.walletStatus === walletTerminal,
|
|
`relabelNo=${relabelNo}, pollRequests=${wallet.attempts}, walletStatus=${wallet.result?.body?.data?.application?.walletStatus || 'missing'}`)
|
|
return { relabelNo, terminalStatus, walletStatus: walletTerminal, freezePollRequests: frozen.attempts, terminalPollRequests: wallet.attempts }
|
|
}
|
|
|
|
async function verifyClientWallet(context, token, fixture, wallet, procurement, relabels) {
|
|
const account = await context.request('GET', `${CLIENT_FINANCE}/account`, undefined, token)
|
|
const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - Number(procurement.settledAmountCny) - 0.01)
|
|
assert(context, '客户工作台余额与冻结结算结果一致', account,
|
|
success(account) && money(account.body.data?.actualBalance) === expectedActual
|
|
&& money(account.body.data?.frozenBalance) === money(wallet.before.frozenBalance)
|
|
&& money(account.body.data?.creditUsed) === money(wallet.before.creditUsed),
|
|
`expectedActual=${expectedActual}, actual=${account.body?.data?.actualBalance ?? 'missing'}, frozen=${account.body?.data?.frozenBalance ?? 'missing'}, creditUsed=${account.body?.data?.creditUsed ?? 'missing'}`)
|
|
const ledger = await context.request('GET', `${CLIENT_FINANCE}/account/ledger?page=1&size=100`, undefined, token)
|
|
const entries = rows(ledger?.body?.data)
|
|
const businessNos = [...procurement.walletBusinessNos, ...relabels.map((item) => item.relabelNo)]
|
|
assert(context, '客户账本包含充值、冻结、结算和释放明细', ledger,
|
|
success(ledger) && entries.some((item) => item.changeType === 'RECHARGE')
|
|
&& businessNos.every((businessNo) => entries.some((item) => item.sourceNo === businessNo))
|
|
&& ['FREEZE', 'CONSUMPTION', 'UNFREEZE'].every((type) => entries.some((item) => item.changeType === type)),
|
|
`entries=${entries.length}, businessNos=${businessNos.join(',')}`)
|
|
return account?.body?.data
|
|
}
|
|
|
|
function summaryValid(value, fields) {
|
|
return fields.every((field) => Number.isInteger(Number(value?.[field])) && Number(value[field]) >= 0)
|
|
}
|
|
|
|
async function verifyPalletSummaries(context, warehouseId) {
|
|
const domestic = await context.request('GET', '/api/platform/v1/pallets/summary?tenantId=0&page=1&size=20')
|
|
const d = domestic?.body?.data
|
|
assert(context, '国内托盘汇总含闲置和占用数量', domestic,
|
|
success(domestic) && summaryValid(d, ['totalCount', 'idleCount', 'occupiedCount'])
|
|
&& Number(d.totalCount) === Number(d.idleCount) + Number(d.occupiedCount),
|
|
`total=${d?.totalCount}, idle=${d?.idleCount}, occupied=${d?.occupiedCount}`)
|
|
const overseas = await context.request('GET', '/api/platform/v1/overseas-warehouse-resources/pallets/summary?page=1&size=20')
|
|
const o = overseas?.body?.data
|
|
assert(context, '海外托盘汇总含闲置、占用和报废数量', overseas,
|
|
success(overseas) && summaryValid(o, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount'])
|
|
&& Number(o.totalCount) === Number(o.idleCount) + Number(o.occupiedCount) + Number(o.scrappedCount),
|
|
`total=${o?.totalCount}, idle=${o?.idleCount}, occupied=${o?.occupiedCount}, scrapped=${o?.scrappedCount}`)
|
|
const warehouse = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`)
|
|
assert(context, '海外仓级托盘汇总可用', warehouse,
|
|
success(warehouse) && summaryValid(warehouse.body.data, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount']))
|
|
}
|
|
|
|
async function verifyDestuffCosts(context, completedFacts) {
|
|
const evidence = []
|
|
for (const fact of completedFacts) {
|
|
const businessNo = `DESTUFF-${fact.containerBookingId}`
|
|
const lastleg = await poll(async () => context.request('GET', query('/api/platform/v1/last-leg/finance-fee-sources', {
|
|
businessType: 'OVERSEAS_CONTAINER', businessNo,
|
|
})), (response) => success(response) && rows(response.body.data).length > 0, 75)
|
|
const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : []
|
|
const allocated = money(sources.reduce((sum, item) => sum + Number(item.amount || 0), 0))
|
|
assert(context, '拆柜打托费用按客户体积分摊且金额守恒', lastleg.result,
|
|
sources.length > 0 && sources.every((item) => item.sourceType === 'OVERSEAS_CONTAINER_FEE'
|
|
&& item.feeItemCode === 'DESTUFF_PALLET' && Number(item.amount) > 0)
|
|
&& allocated === money(fact.expectedPalletCost),
|
|
`containerBookingId=${fact.containerBookingId}, sources=${sources.length}, allocated=${allocated}, expected=${money(fact.expectedPalletCost)}, pollRequests=${lastleg.attempts}`)
|
|
const finance = await poll(async () => context.request('GET', query(`${FINANCE}/fee-sources`, {
|
|
page: 1, pageSize: 200, keyword: businessNo,
|
|
})), (response) => success(response) && sources.length > 0
|
|
&& sources.every((source) => rows(response.body.data).some((item) => id(item.sourceFeeId) === id(source.sourceFeeId))), 60)
|
|
const delivered = success(finance.result) ? rows(finance.result.body.data)
|
|
.filter((item) => sources.some((source) => id(source.sourceFeeId) === id(item.sourceFeeId))) : []
|
|
assert(context, '拆柜打托费用跨区进入Finance且金额守恒', finance.result,
|
|
delivered.length === sources.length
|
|
&& money(delivered.reduce((sum, item) => sum + Number(item.amount || 0), 0)) === money(fact.expectedPalletCost),
|
|
`containerBookingId=${fact.containerBookingId}, delivered=${delivered.length}, expected=${sources.length}, pollRequests=${finance.attempts}`)
|
|
evidence.push({ containerBookingId: fact.containerBookingId, expectedPalletCost: money(fact.expectedPalletCost),
|
|
allocations: sources.length, lastlegPollRequests: lastleg.attempts, financePollRequests: finance.attempts })
|
|
}
|
|
return evidence
|
|
}
|
|
|
|
/** 固定客户只执行采购正常链,不初始化余额、授信或修改公共资源。 */
|
|
export async function runFixedCustomerProcurement(context, fulfill = procurementFlow) {
|
|
const fixture = context.context?.merchantQualificationFixture
|
|
const customerId = normalizeFixedCustomerId(context.options?.fixedCustomerId)
|
|
if (fixture?.marker !== context.marker || id(fixture?.tenantId) !== customerId
|
|
|| fixture?.reused !== true || !fixture?.username || !fixture?.password) {
|
|
throw new Error('采购正向链缺少本轮固定客户资格,禁止创建或借用其他客户')
|
|
}
|
|
if (context.createdResources.procurementSample) throw new Error('已有采购验收证据,不自动重跑或覆盖')
|
|
context.addCoverageGap(MODULE, 'fixed-wallet-lifecycle', '授信充值、贴标资金及拆柜成本综合场景',
|
|
'本分支仅执行既有余额上的采购正向履约;未执行钱包初始化及原综合模块其他场景',
|
|
'not-implemented', { blocking: false })
|
|
const warehousesResponse = await context.request('GET', `${PROCUREMENT}/warehouse-options`)
|
|
const candidates = success(warehousesResponse) ? warehousesResponse.body.data : null
|
|
const warehouse = Array.isArray(candidates) ? candidates.find(row => /^[1-9]\d*$/.test(id(row?.warehouseId))) : null
|
|
assert(context, '采购正向链复用正式候选收货仓', warehousesResponse, Boolean(warehouse))
|
|
if (!warehouse) throw new Error('缺少现有采购合作仓,不自动创建')
|
|
const auth = await merchantLogin(context, fixture)
|
|
if (!auth?.accessToken || !auth?.refreshToken) throw new Error('采购客户会话或退出凭据缺失')
|
|
try {
|
|
const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken)
|
|
const profile = success(profileResponse) ? profileResponse.body.data : null
|
|
const matched = id(profile?.id) === customerId && profile?.status === 'ACTIVE'
|
|
assert(context, '采购当前会话属于指定固定客户', profileResponse, matched)
|
|
if (!matched) throw new Error('采购客户会话身份不匹配,停止业务写入')
|
|
// 共享助手在创建订单前核验现金可用余额至少3元,并逐请求持久化;不足时直接失败。
|
|
return await fulfill(context, fixture, auth.accessToken, id(warehouse.warehouseId))
|
|
} finally {
|
|
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
|
|
assert(context, '退出本轮固定采购客户会话', logout, success(logout))
|
|
}
|
|
}
|
|
|
|
export async function run(context) {
|
|
if (context.options?.fixedCustomerId) return runFixedCustomerProcurement(context)
|
|
const start = context.steps.length
|
|
const fixture = context.context?.merchantQualificationFixture
|
|
const completedFacts = context.createdResources?.phase4OverseasPda?.completedContainerFacts || []
|
|
const warehouseId = context.createdResources?.phase4OverseasPda?.overseasWarehouseId
|
|
|| context.context?.phase4OverseasWarehouse?.warehouseId
|
|
context.assert(MODULE, '钱包闭环必须使用本轮已审核客户和PDA实收柜', null,
|
|
Boolean(fixture?.tenantId && fixture?.username && fixture?.password && warehouseId && completedFacts.length > 0),
|
|
`fixture=${Boolean(fixture)}, warehouseId=${warehouseId || 'missing'}, completedContainers=${completedFacts.length}`, OPTIONS)
|
|
if (!fixture?.tenantId || !warehouseId || completedFacts.length === 0) return
|
|
|
|
const auth = await merchantLogin(context, fixture)
|
|
if (!auth?.accessToken || !auth?.refreshToken) return
|
|
try {
|
|
const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null
|
|
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken, warehouseId) : null
|
|
const settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
|
|
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
|
|
const relabels = [settledRelabel, releasedRelabel].filter(Boolean)
|
|
const finalAccount = procurement && relabels.length === 2
|
|
? await verifyClientWallet(context, auth.accessToken, fixture, wallet, procurement, relabels) : null
|
|
await verifyPalletSummaries(context, warehouseId)
|
|
const destuffCosts = await verifyDestuffCosts(context, completedFacts)
|
|
context.createdResources.phase4OverseasWalletAndPalletCost = {
|
|
marker: context.marker, customerId: id(fixture.tenantId), procurement, relabels,
|
|
finalAccount, completedContainerCosts: destuffCosts,
|
|
}
|
|
const steps = context.steps.slice(start)
|
|
context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed),
|
|
deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed),
|
|
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
|
|
} finally {
|
|
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
|
|
assert(context, '退出本轮钱包客户会话', logout, success(logout))
|
|
}
|
|
|
|
}
|