# Conflicts: # scripts/admin-test-env-acceptance.mjs # scripts/agents-index-contract.ps1 # scripts/full-regression-entry-contract.test.mjs
596 lines
42 KiB
JavaScript
596 lines
42 KiB
JavaScript
const MODULE = 'phase4-overseas-business'
|
|
const SCENARIO = 'phase4-overseas-business-write-lifecycle'
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
|
const BASE = '/api/platform/v1/last-leg'
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
|
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
|
|
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)) }
|
|
function nonEmpty(value) { return typeof value === 'string' && value.trim().length > 0 }
|
|
function pageValid(page) { return Array.isArray(page?.records) && Number(page?.page) >= 1 && Number(page?.size) >= 1 && Number(page?.total) >= page.records.length && Number(page?.pages) >= 0 }
|
|
function ownedRegistry(context) { context.createdResources.phase4OverseasBusiness ??= { marker: context.marker }; return context.createdResources.phase4OverseasBusiness }
|
|
|
|
function blocker(context, name, response, reason) {
|
|
context.addStep(MODULE, name, response || null, false, `${reason}; ${trace(response)}`, OPTIONS)
|
|
return null
|
|
}
|
|
|
|
function expectedConflict(context, name, response, exactVersion = false) {
|
|
const passed = exactVersion ? versionConflict(response) : conflict(response)
|
|
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
|
|
return passed
|
|
}
|
|
|
|
async function domesticGet(context, name, path) {
|
|
const response = await context.request('GET', path)
|
|
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
|
return { response, data }
|
|
}
|
|
|
|
async function domestic(context, method, name, path, body) {
|
|
const response = await context.request(method, path, body)
|
|
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
|
return { response, data }
|
|
}
|
|
|
|
async function overseas(context, method, name, path, body) {
|
|
const response = await context.requestOverseas(method, path, body)
|
|
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
|
return { response, data }
|
|
}
|
|
|
|
function assertPage(context, name, result) {
|
|
context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data),
|
|
`records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS)
|
|
}
|
|
|
|
function partnerWarehousePayload(context) {
|
|
const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase()
|
|
return {
|
|
tenantId: 0, warehouseCode: `P4B-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 采购合作仓`,
|
|
warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', timezone: 'UTC-8', contactName: 'Phase4 QA',
|
|
contactPhone: '12135550177', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-procurement-fixture`,
|
|
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,
|
|
cutoffTime: null, description: `${context.marker} procurement acceptance` }],
|
|
}
|
|
}
|
|
|
|
async function createPartnerWarehouse(context) {
|
|
const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', partnerWarehousePayload(context))
|
|
const warehouseId = id(created.data)
|
|
const valid = longId(warehouseId)
|
|
context.assert(MODULE, '采购合作仓创建返回长ID', created.response, valid,
|
|
`warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
|
if (!valid) return null
|
|
const resource = { marker: context.marker, id: warehouseId, finalStatus: 'ACTIVE', retained: true }
|
|
ownedRegistry(context).partnerWarehouse = resource
|
|
return resource
|
|
}
|
|
|
|
function procurementPayload(context, activeCustomer) {
|
|
return {
|
|
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',
|
|
contactName: '采购验收联系人', contactPhone: '13800000002', remark: context.marker,
|
|
idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255),
|
|
}
|
|
}
|
|
|
|
async function loginMerchant(context, activeCustomer) {
|
|
const fixture = context.context?.merchantQualificationFixture
|
|
if (!nonEmpty(fixture?.username) || !nonEmpty(fixture?.password)) {
|
|
return blocker(context, '采购客户登录前置完整', null,
|
|
`缺少 marker=${context.marker} 的客户账号凭据,禁止使用历史客户或降级为 coverageGap`)
|
|
}
|
|
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 overseasToken = auth?.overseasAccessToken
|
|
context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken),
|
|
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(overseasToken)}; ${trace(login)}`, OPTIONS)
|
|
return nonEmpty(overseasToken) ? overseasToken : null
|
|
}
|
|
|
|
async function createMerchantProcurement(context, activeCustomer, overseasToken) {
|
|
const path = '/api/merchant/v1/last-leg/overseas-procurements'
|
|
const payload = procurementPayload(context, activeCustomer)
|
|
let activeToken = overseasToken
|
|
let response = await context.requestOverseas('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.requestOverseas('POST', path, payload, {}, activeToken)
|
|
}
|
|
const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
|
|
const value = summary(detail)
|
|
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 }
|
|
: null
|
|
if (resource) ownedRegistry(context).purchase = resource
|
|
const valid = nonEmpty(value?.orderNo) && id(value?.customerId) === id(activeCustomer?.id)
|
|
&& value?.status === 'PENDING_REVIEW' && Number(value?.version) === 0
|
|
context.assert(MODULE, '客户采购订单创建为待审核且归属正确', response, valid,
|
|
`orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}, version=${value?.version ?? 'missing'}; ${trace(response)}`, OPTIONS)
|
|
if (!valid) return null
|
|
|
|
const replayResponse = await context.requestOverseas('POST', path, payload, {}, activeToken)
|
|
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
|
|
const replay = summary(replayDetail)
|
|
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)
|
|
|
|
const mismatch = await context.requestOverseas('POST', path,
|
|
{ ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, activeToken)
|
|
expectedConflict(context, '采购创建同幂等键异载荷返回冲突', mismatch)
|
|
|
|
return { resource, payload }
|
|
}
|
|
|
|
async function prerequisites(context) {
|
|
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')
|
|
if (!activeCustomers.length) return blocker(context, '海外业务写验收 ACTIVE 客户前置', tenants.response,
|
|
'IAM 未返回真实 ACTIVE 客户,禁止跳过完整生命周期')
|
|
const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId)
|
|
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 overseasToken = await loginMerchant(context, activeCustomer)
|
|
if (!overseasToken) return null
|
|
const partnerWarehouse = await createPartnerWarehouse(context)
|
|
if (!partnerWarehouse) return null
|
|
const purchase = await createMerchantProcurement(context, activeCustomer, overseasToken)
|
|
if (!purchase) return null
|
|
|
|
const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`)
|
|
const warehouseOptions = await overseas(context, 'GET', '查询 ACTIVE 采购仓候选', `${BASE}/overseas-procurements/warehouse-options`)
|
|
context.assert(MODULE, '采购客户候选结构正确', customerOptions.response,
|
|
Array.isArray(customerOptions.data) && customerOptions.data.every((item) => longId(item?.customerId) && nonEmpty(item?.customerName)),
|
|
`options=${customerOptions.data?.length ?? 'missing'}; ${trace(customerOptions.response)}`, OPTIONS)
|
|
context.assert(MODULE, '采购仓候选结构正确', warehouseOptions.response,
|
|
Array.isArray(warehouseOptions.data) && warehouseOptions.data.every((item) => longId(item?.warehouseId) && nonEmpty(item?.warehouseName)),
|
|
`options=${warehouseOptions.data?.length ?? 'missing'}; ${trace(warehouseOptions.response)}`, OPTIONS)
|
|
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
|
|
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, overseasToken }
|
|
}
|
|
|
|
function assertProcurementHistory(context, result) {
|
|
const histories = Array.isArray(result.data?.histories) ? result.data.histories : []
|
|
const operations = new Set(histories.map((item) => item?.operationType))
|
|
context.assert(MODULE, '采购流水覆盖创建编辑与审核', result.response,
|
|
['CREATE', 'UPDATE', 'REVIEW'].every((type) => operations.has(type))
|
|
&& histories.some((item) => String(item?.remark || item?.changeDetail || '').includes(context.marker)),
|
|
`historyCount=${histories.length}, operations=${[...operations].join(',') || 'missing'}; ${trace(result.response)}`, OPTIONS)
|
|
}
|
|
|
|
async function procurementFlow(context, prerequisite) {
|
|
const customerId = id(prerequisite.procurementCustomer.customerId)
|
|
const warehouseId = id(prerequisite.warehouse.warehouseId)
|
|
const owned = prerequisite.purchase.resource
|
|
const list = await overseas(context, 'GET', '按本轮客户查询采购订单', query(`${BASE}/overseas-procurements`,
|
|
{ page: 1, size: 20, customerId, orderNo: owned.orderNo }))
|
|
assertPage(context, '海外采购订单', list)
|
|
const row = records(list.data).find((item) => item?.orderNo === owned.orderNo && id(item?.customerId) === customerId)
|
|
context.assert(MODULE, '采购列表包含本轮 marker 订单', list.response, Boolean(row),
|
|
`orderNo=${owned.orderNo}, customerId=${customerId}; ${trace(list.response)}`, OPTIONS)
|
|
if (!row) return blocker(context, '采购列表详情前置完整', list.response,
|
|
`列表缺少 marker=${context.marker} 的本轮采购订单`)
|
|
|
|
const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}`, { customerId })
|
|
const detail = await overseas(context, 'GET', '查询本轮采购订单详情', path)
|
|
const before = summary(detail.data)
|
|
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 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.requestOverseas('PATCH', path,
|
|
{ version: Number(setSummary?.version), productSpecification: '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
|
|
&& Number(afterConflictSummary?.version) === Number(clearedSummary?.version),
|
|
`version=${afterConflictSummary?.version ?? 'missing'}, specification=${afterConflictSummary?.productSpecification ?? 'null'}; ${trace(afterConflict.response)}`, OPTIONS)
|
|
|
|
const rejected = await overseas(context, 'POST', '驳回本轮采购订单并进入合法终态',
|
|
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
|
|
{ version: Number(afterConflictSummary?.version), decision: 'REJECT',
|
|
idempotencyKey: `${context.marker}-procurement-review-reject`.slice(0, 255),
|
|
remark: `${context.marker}-REJECTED` })
|
|
const rejectedSummary = summary(rejected.data)
|
|
context.assert(MODULE, '采购审核正向驳回成功', rejected.response,
|
|
rejectedSummary?.status === 'REJECTED' && Number(rejectedSummary?.version) === Number(clearedSummary?.version) + 1,
|
|
`status=${rejectedSummary?.status || 'missing'}, version=${rejectedSummary?.version ?? 'missing'}; ${trace(rejected.response)}`, OPTIONS)
|
|
const rejectedReplayBody = { version: Number(afterConflictSummary?.version), decision: 'REJECT',
|
|
idempotencyKey: `${context.marker}-procurement-review-reject`.slice(0, 255),
|
|
remark: `${context.marker}-REJECTED` }
|
|
const rejectedReplay = await overseas(context, 'POST', '采购审核同键同载荷精确重放',
|
|
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
|
|
rejectedReplayBody)
|
|
context.assert(MODULE, '采购审核重放复用终态且版本不漂移', rejectedReplay.response,
|
|
summary(rejectedReplay.data)?.status === 'REJECTED'
|
|
&& Number(summary(rejectedReplay.data)?.version) === Number(rejectedSummary?.version),
|
|
trace(rejectedReplay.response), OPTIONS)
|
|
const reviewHashConflict = await context.requestOverseas('POST',
|
|
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
|
|
{ ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
|
expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict)
|
|
const reverseReview = await context.requestOverseas('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),
|
|
remark: 'REVERSE-DO-NOT-APPLY' })
|
|
expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview)
|
|
|
|
const final = await overseas(context, 'GET', '采购最终状态与流水回查', path)
|
|
const finalSummary = summary(final.data)
|
|
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)
|
|
assertProcurementHistory(context, final)
|
|
Object.assign(owned, { version: Number(finalSummary?.version), finalStatus: finalSummary?.status,
|
|
retained: finalSummary?.status !== 'REJECTED' })
|
|
}
|
|
|
|
function truckPayload(context) {
|
|
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
|
|
return {
|
|
transportType: 'PORT_PICKUP', origin: `验收起运-${suffix}`, originAddress: 'Long Beach',
|
|
destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', containerType: '40HQ',
|
|
palletCount: 1, packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88,
|
|
currency: 'USD', contactName: '四期验收联系人', contactInfo: '13800000000',
|
|
requestedDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), remark: context.marker,
|
|
idempotencyKey: `${context.marker}-phase4-truck`.slice(0, 255),
|
|
}
|
|
}
|
|
|
|
function looseboardPayload(context) {
|
|
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
|
|
return {
|
|
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
|
|
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
|
|
destinationAddress: 'Ontario', containerType: '20GP', packageCount: 3, totalWeightKg: 80,
|
|
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
|
|
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
|
|
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
|
|
}
|
|
}
|
|
|
|
async function assertCreateIdempotency(context, base, payload, customerId, label) {
|
|
const path = query(base, { customerId })
|
|
const created = await overseas(context, 'POST', `创建 marker ${label}订单`, path, payload)
|
|
const value = summary(created.data)
|
|
if (!nonEmpty(value?.orderNo)) return { created, value, path }
|
|
const replay = await overseas(context, 'POST', `${label}创建同幂等键同载荷精确重放`, path, payload)
|
|
const replayValue = summary(replay.data)
|
|
context.assert(MODULE, `${label}幂等重放复用订单且版本不漂移`, replay.response,
|
|
replayValue?.orderNo === value.orderNo && Number(replayValue?.version) === Number(value.version),
|
|
`orderNo=${id(replayValue?.orderNo) || 'missing'}, version=${replayValue?.version ?? 'missing'}; ${trace(replay.response)}`, OPTIONS)
|
|
const mismatch = await context.requestOverseas('POST', path,
|
|
{ ...payload, destination: `${payload.destination}-DIFFERENT` })
|
|
expectedConflict(context, `${label}创建同幂等键异载荷返回冲突`, mismatch)
|
|
return { created, value, path }
|
|
}
|
|
|
|
async function cancelMarkerOrder(context, resource, base, cancellableStatuses, label) {
|
|
if (!resource || resource.marker !== context.marker || !resource.orderNo || !longId(resource.customerId)) {
|
|
blocker(context, `${label}最终处置资源归属完整`, null,
|
|
`缺少 marker=${context.marker} 的 ${label} 资源登记,无法证明合法终态`)
|
|
return
|
|
}
|
|
const detailPath = query(`${base}/${encodeURIComponent(resource.orderNo)}`, { customerId: resource.customerId })
|
|
const current = await overseas(context, 'GET', `清理前回查 marker ${label}订单`, detailPath)
|
|
const currentSummary = summary(current.data)
|
|
if (currentSummary?.status === 'CANCELLED') {
|
|
resource.finalStatus = 'CANCELLED'; resource.version = Number(currentSummary.version); resource.retained = false
|
|
return
|
|
}
|
|
if (!cancellableStatuses.includes(currentSummary?.status)) {
|
|
resource.finalStatus = currentSummary?.status || resource.finalStatus; resource.version = Number(currentSummary?.version); resource.retained = true
|
|
blocker(context, `${label}测试数据最终处置`, current.response,
|
|
`本轮 marker 资源 ${resource.orderNo} 当前状态 ${resource.finalStatus || 'missing'} 不允许安全取消`)
|
|
return
|
|
}
|
|
const statusPath = query(`${base}/${encodeURIComponent(resource.orderNo)}/status`, { customerId: resource.customerId })
|
|
const keyScope = label === '卡车' ? 'truck' : 'looseboard'
|
|
const cancelled = await overseas(context, 'PATCH', `取消 marker ${label}订单`, statusPath,
|
|
{ version: Number(currentSummary.version), status: 'CANCELLED',
|
|
idempotencyKey: `${context.marker}-${keyScope}-${resource.orderNo}-cancel`.slice(0, 255),
|
|
remark: `${context.marker}-acceptance-cleanup` })
|
|
const cancelledSummary = summary(cancelled.data)
|
|
const verified = await overseas(context, 'GET', `回查 marker ${label}订单最终状态`, detailPath)
|
|
const finalSummary = summary(verified.data)
|
|
const passed = cancelledSummary?.status === 'CANCELLED' && finalSummary?.status === 'CANCELLED'
|
|
&& Number(finalSummary?.version) === Number(cancelledSummary?.version)
|
|
context.assert(MODULE, `${label}本轮测试数据最终合法取消`, verified.response, passed,
|
|
`orderNo=${resource.orderNo}, status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; statusCall=${trace(cancelled.response)}; verify=${trace(verified.response)}`, OPTIONS)
|
|
resource.finalStatus = finalSummary?.status || cancelledSummary?.status || resource.finalStatus
|
|
resource.version = Number(finalSummary?.version ?? cancelledSummary?.version)
|
|
resource.retained = !passed
|
|
}
|
|
|
|
async function truckFlow(context, customerId) {
|
|
const base = `${BASE}/us-truck-orders`
|
|
const payload = truckPayload(context)
|
|
const creation = await assertCreateIdempotency(context, base, payload, customerId, '卡车')
|
|
const created = creation.created; const createdSummary = creation.value
|
|
const orderNo = id(createdSummary?.orderNo)
|
|
context.assert(MODULE, '卡车创建返回本轮业务键和待审核状态', created.response,
|
|
Boolean(orderNo) && id(createdSummary?.customerId) === customerId
|
|
&& createdSummary?.status === 'PENDING_REVIEW' && Number(createdSummary?.version) === 0,
|
|
`orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
|
if (!orderNo) return blocker(context, '卡车完整生命周期创建前置', created.response, '卡车创建未返回订单号')
|
|
const registry = ownedRegistry(context)
|
|
registry.truck = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true }
|
|
const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId })
|
|
try {
|
|
const list = await overseas(context, 'GET', '按本轮客户与单号查询卡车列表', query(base,
|
|
{ page: 1, size: 20, customerId, orderNo }))
|
|
assertPage(context, '本土卡车订单', list)
|
|
context.assert(MODULE, '卡车列表包含本轮 marker 订单', list.response,
|
|
records(list.data).some((item) => item?.orderNo === orderNo && id(item?.customerId) === customerId),
|
|
`orderNo=${orderNo}; ${trace(list.response)}`, OPTIONS)
|
|
const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path)
|
|
const before = summary(detail.data)
|
|
const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path,
|
|
{ version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` })
|
|
const after = summary(edited.data)
|
|
context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response,
|
|
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1
|
|
&& after?.status === 'PENDING_REVIEW',
|
|
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
|
|
const stale = await context.requestOverseas('PATCH', path,
|
|
{ version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
|
expectedConflict(context, '卡车旧 version 编辑返回 A0409', stale, true)
|
|
const afterStale = await overseas(context, 'GET', '卡车旧 version 冲突后零漂移回查', path)
|
|
const afterStaleSummary = summary(afterStale.data)
|
|
context.assert(MODULE, '卡车冲突未造成部分更新', afterStale.response,
|
|
afterStaleSummary?.destination === after?.destination && Number(afterStaleSummary?.version) === Number(after?.version),
|
|
`destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS)
|
|
|
|
const reviewPath = query(`${base}/${encodeURIComponent(orderNo)}/review`, { customerId })
|
|
const reviewBody = { version: Number(afterStaleSummary?.version), decision: 'APPROVE',
|
|
idempotencyKey: `${context.marker}-truck-review-approve`.slice(0, 255),
|
|
remark: `${context.marker}-FORWARD` }
|
|
const forwarded = await overseas(context, 'POST', '卡车审核正向推进到待入仓', reviewPath, reviewBody)
|
|
const forwardedSummary = summary(forwarded.data)
|
|
context.assert(MODULE, '卡车状态正向推进成功', forwarded.response,
|
|
forwardedSummary?.status === 'WAITING_INBOUND'
|
|
&& Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1,
|
|
`status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS)
|
|
const reviewReplay = await overseas(context, 'POST', '卡车审核同键同载荷精确重放', reviewPath, reviewBody)
|
|
context.assert(MODULE, '卡车审核重放复用待入仓终态且版本不漂移', reviewReplay.response,
|
|
summary(reviewReplay.data)?.status === 'WAITING_INBOUND'
|
|
&& Number(summary(reviewReplay.data)?.version) === Number(forwardedSummary?.version),
|
|
trace(reviewReplay.response), OPTIONS)
|
|
const reviewMismatch = await context.requestOverseas('POST', reviewPath,
|
|
{ ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
|
expectedConflict(context, '卡车审核同键异载荷返回冲突', reviewMismatch)
|
|
const reversed = await context.requestOverseas('POST', reviewPath,
|
|
{ version: Number(forwardedSummary?.version), decision: 'REJECT',
|
|
idempotencyKey: `${context.marker}-truck-review-terminal-reverse`.slice(0, 255),
|
|
remark: 'REVERSE-DO-NOT-APPLY' })
|
|
expectedConflict(context, '卡车审核终态逆向返回冲突', reversed)
|
|
const history = await overseas(context, 'GET', '卡车状态冲突后流水回查', path)
|
|
const historySummary = summary(history.data)
|
|
const histories = Array.isArray(history.data?.histories) ? history.data.histories : []
|
|
context.assert(MODULE, '卡车流水覆盖创建编辑与正向状态', history.response,
|
|
historySummary?.status === 'WAITING_INBOUND' && histories.length >= 3
|
|
&& histories.some((item) => String(item?.remark || '').includes(context.marker)),
|
|
`status=${historySummary?.status || 'missing'}, historyCount=${histories.length}; ${trace(history.response)}`, OPTIONS)
|
|
Object.assign(registry.truck, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
|
|
} finally {
|
|
await cancelMarkerOrder(context, registry.truck, base, ['PENDING_REVIEW', 'WAITING_INBOUND'], '卡车')
|
|
}
|
|
}
|
|
|
|
async function looseboardFlow(context, customerId) {
|
|
const base = `${BASE}/local-looseboard-orders`
|
|
const payload = looseboardPayload(context)
|
|
const creation = await assertCreateIdempotency(context, base, payload, customerId, '散板')
|
|
const created = creation.created; const createdSummary = creation.value
|
|
const orderNo = id(createdSummary?.orderNo)
|
|
context.assert(MODULE, '散板创建返回本轮业务键和待接单状态', created.response,
|
|
Boolean(orderNo) && id(createdSummary?.customerId) === customerId
|
|
&& createdSummary?.status === 'PENDING_ACCEPTANCE' && Number(createdSummary?.version) === 0,
|
|
`orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
|
if (!orderNo) return blocker(context, '散板完整生命周期创建前置', created.response, '散板创建未返回订单号')
|
|
const registry = ownedRegistry(context)
|
|
registry.looseboard = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true }
|
|
const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId })
|
|
try {
|
|
const list = await overseas(context, 'GET', '按本轮客户与单号查询散板列表', query(base,
|
|
{ page: 1, size: 20, customerId, orderNo }))
|
|
assertPage(context, '本土散板订单', list)
|
|
context.assert(MODULE, '散板列表包含本轮 marker 订单', list.response,
|
|
records(list.data).some((item) => item?.orderNo === orderNo && id(item?.customerId) === customerId),
|
|
`orderNo=${orderNo}; ${trace(list.response)}`, OPTIONS)
|
|
const detail = await overseas(context, 'GET', '查询 marker 本土散板详情', path)
|
|
const before = summary(detail.data)
|
|
const { idempotencyKey: _key, ...editable } = payload
|
|
const edited = await overseas(context, 'PUT', '编辑 marker 本土散板订单', path,
|
|
{ ...editable, version: Number(before?.version), destination: `${payload.destination}-EDIT`,
|
|
remark: `${context.marker}-looseboard-edited` })
|
|
const after = summary(edited.data)
|
|
context.assert(MODULE, '散板编辑版本递增且普通字段回显', edited.response,
|
|
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1
|
|
&& after?.status === 'PENDING_ACCEPTANCE',
|
|
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
|
|
const stale = await context.requestOverseas('PUT', path,
|
|
{ ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
|
expectedConflict(context, '散板旧 version 编辑返回 A0409', stale, true)
|
|
const afterStale = await overseas(context, 'GET', '散板旧 version 冲突后零漂移回查', path)
|
|
const afterStaleSummary = summary(afterStale.data)
|
|
context.assert(MODULE, '散板冲突未造成部分更新', afterStale.response,
|
|
afterStaleSummary?.destination === after?.destination && Number(afterStaleSummary?.version) === Number(after?.version),
|
|
`destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS)
|
|
|
|
const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId })
|
|
const forwarded = await overseas(context, 'PATCH', '散板状态正向推进到已派车', statusPath,
|
|
{ version: Number(afterStaleSummary?.version), status: 'DISPATCHED',
|
|
idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255),
|
|
remark: `${context.marker}-FORWARD` })
|
|
const forwardedSummary = summary(forwarded.data)
|
|
context.assert(MODULE, '散板状态正向推进成功', forwarded.response,
|
|
forwardedSummary?.status === 'DISPATCHED'
|
|
&& Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1,
|
|
`status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS)
|
|
const forwardBody = { version: Number(afterStaleSummary?.version), status: 'DISPATCHED',
|
|
idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255),
|
|
remark: `${context.marker}-FORWARD` }
|
|
const forwardReplay = await overseas(context, 'PATCH', '散板状态同键同载荷精确重放', statusPath,
|
|
forwardBody)
|
|
context.assert(MODULE, '散板状态重放复用已派车终态且版本不漂移', forwardReplay.response,
|
|
summary(forwardReplay.data)?.status === 'DISPATCHED'
|
|
&& Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version),
|
|
trace(forwardReplay.response), OPTIONS)
|
|
const forwardMismatch = await context.requestOverseas('PATCH', statusPath,
|
|
{ ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
|
expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch)
|
|
const reversed = await context.requestOverseas('PATCH', statusPath,
|
|
{ version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE',
|
|
idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255),
|
|
remark: 'REVERSE-DO-NOT-APPLY' })
|
|
expectedConflict(context, '散板状态逆向回退返回冲突', reversed)
|
|
const history = await overseas(context, 'GET', '散板状态冲突后流水回查', path)
|
|
const historySummary = summary(history.data)
|
|
const histories = Array.isArray(history.data?.histories) ? history.data.histories : []
|
|
const operations = new Set(histories.map((item) => item?.operationType))
|
|
context.assert(MODULE, '散板流水覆盖创建编辑与正向状态', history.response,
|
|
historySummary?.status === 'DISPATCHED' && ['CREATE', 'UPDATE', 'STATUS'].every((type) => operations.has(type))
|
|
&& histories.some((item) => String(item?.remark || item?.changeDetail || '').includes(context.marker)),
|
|
`status=${historySummary?.status || 'missing'}, historyCount=${histories.length}, operations=${[...operations].join(',') || 'missing'}; ${trace(history.response)}`, OPTIONS)
|
|
Object.assign(registry.looseboard, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
|
|
} finally {
|
|
await cancelMarkerOrder(context, registry.looseboard, base, ['PENDING_ACCEPTANCE', 'DISPATCHED'], '散板')
|
|
}
|
|
}
|
|
|
|
async function retirePartnerWarehouse(context, resource) {
|
|
if (!resource?.id) return
|
|
const retired = await context.request('PATCH',
|
|
`/api/platform/v1/warehouse-admin/${encodeURIComponent(resource.id)}/status?tenantId=0&status=INACTIVE`)
|
|
const passed = retired?.http === 200 && codeOf(retired) === '200'
|
|
context.addStep(MODULE, '本轮采购合作仓已停用收口', retired, passed, trace(retired), OPTIONS)
|
|
resource.finalStatus = passed ? 'INACTIVE' : resource.finalStatus
|
|
resource.retained = !passed
|
|
}
|
|
|
|
async function finalizePendingPurchase(context, resource) {
|
|
if (!resource?.orderNo || !longId(resource?.customerId)
|
|
|| ['REJECTED', 'APPROVED'].includes(resource.finalStatus)) return
|
|
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)) {
|
|
resource.finalStatus = currentSummary.status; resource.version = Number(currentSummary.version); resource.retained = false
|
|
return
|
|
}
|
|
if (currentSummary?.status !== 'PENDING_REVIEW') {
|
|
blocker(context, '采购测试数据最终处置', current.response,
|
|
`本轮 marker 资源 ${resource.orderNo} 当前状态 ${currentSummary?.status || 'missing'} 无法安全驳回`)
|
|
resource.finalStatus = currentSummary?.status || resource.finalStatus; resource.retained = true
|
|
return
|
|
}
|
|
const rejected = await overseas(context, 'POST', '驳回未完成的本轮采购订单',
|
|
query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}/review`,
|
|
{ customerId: resource.customerId }),
|
|
{ version: Number(currentSummary?.version), decision: 'REJECT',
|
|
idempotencyKey: `${context.marker}-procurement-review-cleanup`.slice(0, 255),
|
|
remark: `${context.marker}-prerequisite-cleanup` })
|
|
const rejectedSummary = summary(rejected.data)
|
|
const verified = await overseas(context, 'GET', '回查未完成采购订单最终状态', path)
|
|
const finalSummary = summary(verified.data)
|
|
const passed = rejectedSummary?.status === 'REJECTED' && finalSummary?.status === 'REJECTED'
|
|
&& Number(finalSummary?.version) === Number(rejectedSummary?.version)
|
|
context.assert(MODULE, '采购本轮测试数据最终合法驳回', verified.response, passed,
|
|
`orderNo=${resource.orderNo}, status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; review=${trace(rejected.response)}; verify=${trace(verified.response)}`, OPTIONS)
|
|
resource.finalStatus = finalSummary?.status || rejectedSummary?.status || resource.finalStatus
|
|
resource.version = Number(finalSummary?.version ?? rejectedSummary?.version)
|
|
resource.retained = !passed
|
|
}
|
|
|
|
export async function run(context) {
|
|
let prerequisite = null
|
|
try {
|
|
prerequisite = await prerequisites(context)
|
|
if (!prerequisite) return
|
|
await procurementFlow(context, prerequisite)
|
|
const customerId = id(prerequisite.activeCustomer.id)
|
|
await truckFlow(context, customerId)
|
|
await looseboardFlow(context, customerId)
|
|
} finally {
|
|
const registry = context.createdResources.phase4OverseasBusiness
|
|
await finalizePendingPurchase(context, registry?.purchase)
|
|
await retirePartnerWarehouse(context, registry?.partnerWarehouse)
|
|
}
|
|
}
|