feat(procurement): implement sample fulfillment and settled fee workflow
This commit is contained in:
@@ -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,无法确认会话退出')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user