feat(procurement): implement sample fulfillment and settled fee workflow

This commit is contained in:
2026-10-10 02:19:38 +08:00
parent 040f3bbd93
commit a973be2103
104 changed files with 9823 additions and 714 deletions
@@ -9,7 +9,6 @@ function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${c
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function query(path, params) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) if (value !== '' && value != null) search.set(key, String(value)); return `${path}?${search}` }
function summary(data) { return data?.summary && typeof data.summary === 'object' ? data.summary : data }
function detailValue(data, field) { return data && Object.hasOwn(data, field) ? data[field] : summary(data)?.[field] }
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response).endsWith('409')) }
function versionConflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' }
function longId(value) { return /^[1-9]\d*$/.test(id(value)) }
@@ -60,7 +59,7 @@ function partnerWarehousePayload(context) {
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null,
latitude: null, longitude: null },
capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true,
capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: 'Receiving', enabled: true,
cutoffTime: null, description: `${context.marker} procurement acceptance` }],
}
}
@@ -82,13 +81,13 @@ function procurementPayload(context, activeCustomer) {
customerName: activeCustomer?.tenantName || activeCustomer?.name || `Customer-${id(activeCustomer?.id)}`,
purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 采购商品`,
productSpecification: 'Phase4 acceptance', productUrl: null, purchaseQuantity: 10,
budgetUnitPrice: 1.25, currency: 'USD', receivingAddress: 'Los Angeles',
budgetUnitPrice: 1.25, currency: 'USD', receivingAddress: '广东省深圳市南山区科技园测试路 1 号',
contactName: '采购验收联系人', contactPhone: '13800000002', remark: context.marker,
idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255),
}
}
async function loginMerchant(context, activeCustomer) {
async function loginMerchant(context, activeCustomer, session) {
const fixture = context.context?.merchantQualificationFixture
if (!nonEmpty(fixture?.username) || !nonEmpty(fixture?.password)) {
return blocker(context, '采购客户登录前置完整', null,
@@ -97,6 +96,7 @@ async function loginMerchant(context, activeCustomer) {
const login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS)
const token = auth?.accessToken
session.accessToken = token; session.refreshToken = auth?.refreshToken
context.assert(MODULE, '采购客户登录返回统一Token', login, nonEmpty(token),
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(token)}; ${trace(login)}`, OPTIONS)
return nonEmpty(token) ? token : null
@@ -105,22 +105,10 @@ async function loginMerchant(context, activeCustomer) {
async function createMerchantProcurement(context, activeCustomer, token) {
const path = '/api/merchant/v1/last-leg/overseas-procurements'
const payload = procurementPayload(context, activeCustomer)
let activeToken = token
let response = await context.request('POST', path, payload, activeToken, {})
// A newly approved customer is projected to the overseas region asynchronously. The
// domestic login can therefore succeed a few seconds before the overseas gateway has
// received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact
// security-projection conflict and always issue a fresh short-lived overseas token.
for (let attempt = 1; attempt <= 12
&& response?.http === 403 && codeOf(response) === 'A0403'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
const refreshedToken = await loginMerchant(context, activeCustomer)
if (!refreshedToken) break
activeToken = refreshedToken
response = await context.request('POST', path, payload, activeToken, {})
}
const activeToken = token
const response = await context.request('POST', path, payload, activeToken, {})
const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
const value = summary(detail)
const value = detail?.order
const resource = nonEmpty(value?.orderNo) && longId(value?.customerId)
? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId),
version: Number(value.version), finalStatus: value.status, retained: true }
@@ -134,7 +122,7 @@ async function createMerchantProcurement(context, activeCustomer, token) {
const replayResponse = await context.request('POST', path, payload, activeToken, {})
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
const replay = summary(replayDetail)
const replay = replayDetail?.order
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version),
`orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS)
@@ -145,7 +133,7 @@ async function createMerchantProcurement(context, activeCustomer, token) {
return { resource, payload }
}
async function prerequisites(context) {
async function prerequisites(context, session) {
const tenants = await domesticGet(context, '查询 ACTIVE 客户候选', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
assertPage(context, 'ACTIVE 客户候选', tenants)
const activeCustomers = records(tenants.data).filter((item) => longId(item?.id) && item?.status === 'ACTIVE')
@@ -155,7 +143,7 @@ async function prerequisites(context) {
const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
const token = await loginMerchant(context, activeCustomer)
const token = await loginMerchant(context, activeCustomer, session)
if (!token) return null
const partnerWarehouse = await createPartnerWarehouse(context)
if (!partnerWarehouse) return null
@@ -173,18 +161,17 @@ async function prerequisites(context) {
const procurementCustomer = Array.isArray(customerOptions.data)
? customerOptions.data.find((item) => id(item?.customerId) === id(activeCustomer.id)) : null
const warehouse = Array.isArray(warehouseOptions.data)
? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id)
|| warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null
? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id) : null
if (!procurementCustomer) return blocker(context, '采购客户候选包含本轮客户', customerOptions.response,
`本轮客户 ${id(activeCustomer.id)} 未进入采购客户候选,禁止继续写验收`)
if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response,
'采购仓候选为空,禁止跳过采购三态清空')
'采购仓候选为空,禁止跳过采购备注与审核边界')
return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions,
partnerWarehouse, purchase, token }
}
function assertProcurementHistory(context, result) {
const histories = Array.isArray(result.data?.histories) ? result.data.histories : []
const histories = Array.isArray(result.data?.reviewHistory) ? result.data.reviewHistory : []
const operations = new Set(histories.map((item) => item?.operationType))
context.assert(MODULE, '采购流水覆盖创建编辑与审核', result.response,
['CREATE', 'UPDATE', 'REVIEW'].every((type) => operations.has(type))
@@ -207,55 +194,42 @@ async function procurementFlow(context, prerequisite) {
const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}`, { customerId })
const detail = await overseas(context, 'GET', '查询本轮采购订单详情', path)
const before = summary(detail.data)
const before = detail.data?.customerView?.order
context.assert(MODULE, '采购详情归属和初始版本正确', detail.response,
before?.orderNo === owned.orderNo && id(before?.customerId) === customerId && Number(before?.version) === 0,
`orderNo=${id(before?.orderNo) || 'missing'}, version=${before?.version ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
const setResult = await overseas(context, 'PATCH', '采购可空字段设置非空值', path,
{ version: Number(before?.version), productSpecification: `${context.marker}-SPEC`,
productUrl: `phase4-product-${context.marker}`, purchaseWarehouseId: warehouseId,
purchaseWarehouseName: prerequisite.warehouse.warehouseName, domesticCarrier: `${context.marker}-CARRIER`,
domesticTrackingNo: `${context.marker}-TRACK`, estimatedArrivalAt: '2026-12-31T12:00:00', remark: context.marker })
const setSummary = summary(setResult.data)
context.assert(MODULE, '采购三态第一态设置值并递增版本', setResult.response,
setSummary?.productSpecification === `${context.marker}-SPEC`
&& detailValue(setResult.data, 'productUrl')?.includes(context.marker)
&& id(setSummary?.purchaseWarehouseId) === warehouseId
&& Number(setSummary?.version) === Number(before?.version) + 1,
`version=${setSummary?.version ?? 'missing'}, warehouseId=${id(setSummary?.purchaseWarehouseId) || 'missing'}; ${trace(setResult.response)}`, OPTIONS)
const setResult = await overseas(context, 'PATCH', '设置采购内部备注', path,
{ version: Number(before?.version), remark: `${context.marker}-edited` })
const setSummary = setResult.data?.summary
context.assert(MODULE, '采购备注设置并递增版本', setResult.response,
setResult.data?.remark === `${context.marker}-edited` && Number(setSummary?.version) === Number(before?.version) + 1,
trace(setResult.response), OPTIONS)
const omittedResult = await overseas(context, 'PATCH', '采购可空字段缺失保持原值', path,
{ version: Number(setSummary?.version), domesticCarrier: `${context.marker}-CARRIER-2` })
const omittedSummary = summary(omittedResult.data)
context.assert(MODULE, '采购三态第二态字段缺失不修改', omittedResult.response,
omittedSummary?.productSpecification === setSummary?.productSpecification
&& detailValue(omittedResult.data, 'productUrl') === detailValue(setResult.data, 'productUrl')
&& id(omittedSummary?.purchaseWarehouseId) === warehouseId
&& Number(omittedSummary?.version) === Number(setSummary?.version) + 1,
`version=${omittedSummary?.version ?? 'missing'}, specification=${omittedSummary?.productSpecification || 'missing'}; ${trace(omittedResult.response)}`, OPTIONS)
const clearedResult = await overseas(context, 'PATCH', '采购可空字段显式 null 与空串清空', path,
{ version: Number(omittedSummary?.version), productSpecification: null, productUrl: '', purchaseWarehouseId: null,
purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: '', estimatedArrivalAt: null, remark: null })
const clearedSummary = summary(clearedResult.data)
context.assert(MODULE, '采购三态第三态显式清空全部生效', clearedResult.response,
clearedSummary?.productSpecification == null && detailValue(clearedResult.data, 'productUrl') == null
&& clearedSummary?.purchaseWarehouseId == null && clearedSummary?.purchaseWarehouseName == null
&& detailValue(clearedResult.data, 'domesticCarrier') == null
&& detailValue(clearedResult.data, 'domesticTrackingNo') == null
&& detailValue(clearedResult.data, 'estimatedArrivalAt') == null
&& Number(clearedSummary?.version) === Number(omittedSummary?.version) + 1,
`version=${clearedSummary?.version ?? 'missing'}, specification=${clearedSummary?.productSpecification ?? 'null'}, warehouseId=${clearedSummary?.purchaseWarehouseId ?? 'null'}; ${trace(clearedResult.response)}`, OPTIONS)
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' })
const omittedResult = await overseas(context, 'PATCH', '采购备注缺失保持原值', path,
{ version: Number(setSummary?.version) })
context.assert(MODULE, '缺失备注不修改且版本不漂移', omittedResult.response,
omittedResult.data?.remark === `${context.marker}-edited` && Number(omittedResult.data?.summary?.version) === Number(setSummary?.version),
trace(omittedResult.response), OPTIONS)
const clearedResult = await overseas(context, 'PATCH', '采购备注显式清空', path,
{ version: Number(setSummary?.version), remark: null })
const clearedSummary = clearedResult.data?.summary
context.assert(MODULE, '采购备注清空只变更本字段', clearedResult.response,
clearedResult.data?.remark == null && Number(clearedSummary?.version) === Number(setSummary?.version) + 1,
trace(clearedResult.response), OPTIONS)
const forbidden = await context.request('PATCH', path,
{ version: Number(clearedSummary?.version), domesticTrackingNo: 'FORBIDDEN' })
context.assert(MODULE, '通用编辑拒绝旧履约字段', forbidden,
forbidden?.http === 400 || (forbidden?.http === 200 && codeOf(forbidden) === 'A0400'), trace(forbidden), OPTIONS)
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), remark: 'STALE-DO-NOT-APPLY' })
expectedConflict(context, '采购旧 version 返回 A0409', stale, true)
const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path)
const afterConflictSummary = summary(afterConflict.data)
context.assert(MODULE, '采购旧 version 冲突未造成部分更新', afterConflict.response,
afterConflictSummary?.productSpecification == null
const afterConflictSummary = afterConflict.data?.customerView?.order
context.assert(MODULE, '采购拒绝请求不改变商品与版本', afterConflict.response,
afterConflictSummary?.productSpecification === before?.productSpecification
&& afterConflict.data?.internalRemark == null
&& Number(afterConflictSummary?.version) === Number(clearedSummary?.version),
`version=${afterConflictSummary?.version ?? 'missing'}, specification=${afterConflictSummary?.productSpecification ?? 'null'}; ${trace(afterConflict.response)}`, OPTIONS)
trace(afterConflict.response), OPTIONS)
const rejected = await overseas(context, 'POST', '驳回本轮采购订单并进入合法终态',
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
@@ -279,12 +253,12 @@ async function procurementFlow(context, prerequisite) {
const reviewHashConflict = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict)
const reverseReview = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { version: Number(rejectedSummary?.version), decision: 'APPROVE',
idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255),
purchaseWarehouseId: warehouseId, idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255),
remark: 'REVERSE-DO-NOT-APPLY' })
expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview)
const final = await overseas(context, 'GET', '采购最终状态与流水回查', path)
const finalSummary = summary(final.data)
const finalSummary = final.data?.customerView?.order
context.assert(MODULE, '采购 marker 资源最终保持合法终态', final.response,
finalSummary?.status === 'REJECTED' && Number(finalSummary?.version) === Number(rejectedSummary?.version),
`status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS)
@@ -536,8 +510,8 @@ async function finalizePendingPurchase(context, resource) {
const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}`,
{ customerId: resource.customerId })
const current = await overseas(context, 'GET', '采购最终处置前回查本轮订单', path)
const currentSummary = summary(current.data)
if (['REJECTED', 'APPROVED'].includes(currentSummary?.status)) {
const currentSummary = current.data?.customerView?.order
if (['REJECTED', 'CANCELLED'].includes(currentSummary?.status)) {
resource.finalStatus = currentSummary.status; resource.version = Number(currentSummary.version); resource.retained = false
return
}
@@ -555,7 +529,7 @@ async function finalizePendingPurchase(context, resource) {
remark: `${context.marker}-prerequisite-cleanup` })
const rejectedSummary = summary(rejected.data)
const verified = await overseas(context, 'GET', '回查未完成采购订单最终状态', path)
const finalSummary = summary(verified.data)
const finalSummary = verified.data?.customerView?.order
const passed = rejectedSummary?.status === 'REJECTED' && finalSummary?.status === 'REJECTED'
&& Number(finalSummary?.version) === Number(rejectedSummary?.version)
context.assert(MODULE, '采购本轮测试数据最终合法驳回', verified.response, passed,
@@ -567,8 +541,9 @@ async function finalizePendingPurchase(context, resource) {
export async function run(context) {
let prerequisite = null
const session = {}
try {
prerequisite = await prerequisites(context)
prerequisite = await prerequisites(context, session)
if (!prerequisite) return
await procurementFlow(context, prerequisite)
const customerId = id(prerequisite.activeCustomer.id)
@@ -576,7 +551,16 @@ export async function run(context) {
await looseboardFlow(context, customerId)
} finally {
const registry = context.createdResources.phase4OverseasBusiness
await finalizePendingPurchase(context, registry?.purchase)
await retirePartnerWarehouse(context, registry?.partnerWarehouse)
try {
await finalizePendingPurchase(context, registry?.purchase)
await retirePartnerWarehouse(context, registry?.partnerWarehouse)
} finally {
if (nonEmpty(session.refreshToken)) {
const loggedOut = await context.request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken || '')
context.apiSuccess(MODULE, '退出本轮采购客户会话', loggedOut, trace(loggedOut), OPTIONS)
} else if (nonEmpty(session.accessToken)) {
blocker(context, '采购客户会话退出凭据缺失', null, '登录响应缺少 refreshToken,无法确认会话退出')
}
}
}
}