test(firstleg): cover phase3 container loading flow
This commit is contained in:
@@ -11,7 +11,7 @@ import process from 'node:process'
|
||||
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
||||
const DEFAULT_USERNAME = 'platform_admin'
|
||||
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
||||
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow']
|
||||
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow']
|
||||
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
||||
const CUSTOMER_FIELDS = [
|
||||
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
||||
@@ -435,6 +435,47 @@ class AcceptanceRun {
|
||||
}
|
||||
}
|
||||
|
||||
async preparePhase3ContainerFixture() {
|
||||
const originalMarker = this.marker
|
||||
const fixtureMarker = `${originalMarker}-CAB`
|
||||
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
|
||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
|
||||
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
||||
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
|
||||
}]))
|
||||
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
|
||||
present: Object.hasOwn(this.context, key), value: this.context[key],
|
||||
}]))
|
||||
|
||||
this.marker = fixtureMarker
|
||||
const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
|
||||
await this.executeModule('orders-phase3-container-fixture', () => orders.run(this))
|
||||
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
||||
await this.executeModule('inbound-measurement-phase3-container-fixture', () => measurement.run(this))
|
||||
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
|
||||
this.createdResources.orchestrationFixtures = {
|
||||
...(this.createdResources.orchestrationFixtures || {}),
|
||||
phase3Container: fixture ? {
|
||||
marker: fixtureMarker,
|
||||
tenantId: fixture.tenantId,
|
||||
orderId: fixture.orderId || this.createdResources.orders?.orderId,
|
||||
inboundId: fixture.inboundId,
|
||||
} : { marker: fixtureMarker, failed: true },
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.marker = originalMarker
|
||||
for (const [key, snapshot] of createdSnapshot) {
|
||||
if (snapshot.present) this.createdResources[key] = snapshot.value
|
||||
else delete this.createdResources[key]
|
||||
}
|
||||
for (const [key, snapshot] of contextSnapshot) {
|
||||
if (snapshot.present) this.context[key] = snapshot.value
|
||||
else delete this.context[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async run() {
|
||||
const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '')
|
||||
const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse)
|
||||
@@ -455,6 +496,15 @@ class AcceptanceRun {
|
||||
{ scenario: 'inbound-resource-isolation' })
|
||||
}
|
||||
}
|
||||
if (module === 'phase3-container-loading') {
|
||||
try {
|
||||
restoreFixture = await this.preparePhase3ContainerFixture()
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.addStep('phase3-container-fixture', '创建独立柜次级入库单', null, false, reason,
|
||||
{ scenario: 'phase3-container-resource-isolation' })
|
||||
}
|
||||
}
|
||||
let handler = handlers[module]
|
||||
if (!handler) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
const MODULE = 'phase3-container-loading'
|
||||
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
const FIRST_LEG = '/api/platform/v1/first-leg'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function failure(response) {
|
||||
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|
||||
|| (response?.http === 200 && codeOf(response) !== '200')
|
||||
}
|
||||
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method, path, http: response.status, body: payload,
|
||||
raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(context) {
|
||||
return context.createdResources?.orchestrationFixtures?.phase3Container
|
||||
|| context.createdResources?.inboundMeasurement
|
||||
|| context.createdResources?.orders
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const source = fixture(context)
|
||||
const tenantId = id(source?.tenantId)
|
||||
const inboundId = id(source?.inboundId)
|
||||
if (!tenantId || !inboundId || source?.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得独立柜次级 INBOUNDED fixture', null, false,
|
||||
`fixture marker=${source?.marker || 'missing'}, current marker=${context.marker}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
const inboundResponse = await context.request('GET',
|
||||
`/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const inbound = context.apiSuccess(MODULE, '读取柜次级独立入库单', inboundResponse,
|
||||
`tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
|
||||
const warehouseId = id(inbound?.summary?.warehouseId)
|
||||
context.assert(MODULE, '柜次候选入库单状态、测量和仓库门禁有效', inboundResponse,
|
||||
inbound?.summary?.processStatus === 'INBOUNDED' && inbound?.summary?.measureReviewStatus === 'APPROVED'
|
||||
&& Boolean(warehouseId),
|
||||
`status=${inbound?.summary?.processStatus || 'missing'}, review=${inbound?.summary?.measureReviewStatus || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
|
||||
if (!warehouseId) return
|
||||
|
||||
const candidateResponse = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const candidates = context.apiSuccess(MODULE, '查询智能装柜真实候选货物', candidateResponse, '', OPTIONS)
|
||||
const candidate = Array.isArray(candidates)
|
||||
? candidates.find((item) => id(item.inboundId) === inboundId && Number(item.availablePackageCount) > 0)
|
||||
: null
|
||||
context.assert(MODULE, '候选货物归属本轮入库单且剩余箱数有效', candidateResponse,
|
||||
Boolean(candidate?.inboundDetailId) && id(candidate?.warehouseId) === warehouseId
|
||||
&& id(candidate?.accountId) === tenantId,
|
||||
`candidateFound=${Boolean(candidate)}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!candidate?.inboundDetailId) return
|
||||
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
|
||||
const containerNo = `P3C-${suffix}`.slice(0, 64)
|
||||
const cabinetNo = `P3I-${suffix}`.slice(0, 64)
|
||||
const containerPayload = {
|
||||
containerNo, cabinetNo, warehouseId, warehouseName: inbound?.summary?.warehouseName || null,
|
||||
billOfLadingNo: `BL-${suffix}`, vesselName: `P3-VESSEL-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
|
||||
containerType: '40HQ', shippingCompanyId: null, shippingCompanyName: `P3-SHIPPING-${suffix}`.slice(0, 128),
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
destinationWarehouseCode: candidate.destinationWarehouseCode || null,
|
||||
destinationWarehouseName: candidate.destinationWarehouseName || null,
|
||||
capacityCbm: 100, maxWeightKg: 100000, terminalTruckingCompany: `P3-TRUCK-${suffix}`.slice(0, 128),
|
||||
loadingAt: '2026-09-02T08:00:00', estimatedSailingAt: '2026-09-03T08:00:00',
|
||||
estimatedPortArrivalAt: '2026-09-20T08:00:00', estimatedWarehouseArrivalAt: '2026-09-21T08:00:00',
|
||||
}
|
||||
const createContainerResponse = await request(context, 'POST', CONTAINERS, containerPayload)
|
||||
const container = context.apiSuccess(MODULE, '创建 marker 隔离货柜', createContainerResponse,
|
||||
`containerNo=${containerNo}`, OPTIONS)
|
||||
const containerId = id(container?.id)
|
||||
context.assert(MODULE, '货柜归属仓库、路线、版本和编号正确', createContainerResponse,
|
||||
Boolean(containerId) && container?.containerNo === containerNo && container?.cabinetNo === cabinetNo
|
||||
&& id(container?.warehouseId) === warehouseId && Number(container?.version) === 0,
|
||||
`containerId=${containerId || 'missing'}, version=${container?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!containerId) return
|
||||
|
||||
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
|
||||
const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
|
||||
containerDetail?.destinationWarehouseCode === (candidate.destinationWarehouseCode || null)
|
||||
&& containerDetail?.planStatus === 'EDITING',
|
||||
`planStatus=${containerDetail?.planStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const planResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker })
|
||||
const plan = context.apiSuccess(MODULE, '创建智能装柜计划', planResponse, '', OPTIONS)
|
||||
const planId = id(plan?.id)
|
||||
context.assert(MODULE, '装柜计划与货柜关联且处于 EDITING', planResponse,
|
||||
Boolean(planId) && id(plan?.bookingId) === containerId && plan?.status === 'EDITING'
|
||||
&& Number(plan?.version) === 0 && Array.isArray(plan?.items) && plan.items.length === 0,
|
||||
`planId=${planId || 'missing'}, status=${plan?.status || 'missing'}`, OPTIONS)
|
||||
if (!planId) return
|
||||
|
||||
const planReplayResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: 'replay' })
|
||||
const planReplay = context.apiSuccess(MODULE, '重放货柜装柜计划创建', planReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '同一货柜重放返回同一计划', planReplayResponse,
|
||||
id(planReplay?.id) === planId, `first=${planId}, replay=${id(planReplay?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
const assignPayload = {
|
||||
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(plan.version), operationId: `${context.marker}-container-assign`.slice(0, 128),
|
||||
}
|
||||
const assignResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload)
|
||||
const assigned = context.apiSuccess(MODULE, '人工分配本轮候选货物', assignResponse, '', OPTIONS)
|
||||
const planItem = assigned?.items?.find?.((item) => id(item.inboundId) === inboundId)
|
||||
context.assert(MODULE, '分配明细归属、箱数和计划版本正确', assignResponse,
|
||||
Boolean(planItem?.id) && Number(planItem?.packageCount) === 1 && Number(assigned?.version) === 1,
|
||||
`itemId=${id(planItem?.id) || 'missing'}, version=${assigned?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!planItem?.id) return
|
||||
|
||||
const assignReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload)
|
||||
const assignReplay = context.apiSuccess(MODULE, '分配 operationId 幂等重放', assignReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '重放不重复增加装柜箱数', assignReplayResponse,
|
||||
assignReplay?.items?.length === 1 && Number(assignReplay.items[0]?.packageCount) === 1
|
||||
&& Number(assignReplay?.version) === Number(assigned?.version),
|
||||
`items=${assignReplay?.items?.length ?? 'n/a'}, version=${assignReplay?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const staleAssignResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, {
|
||||
...assignPayload, expectedVersion: 0, operationId: `${context.marker}-container-stale`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, '旧计划版本分配返回冲突', staleAssignResponse,
|
||||
staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)),
|
||||
`HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS)
|
||||
|
||||
const pushKey = `${context.marker}-container-push`.slice(0, 128)
|
||||
const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
|
||||
const pushed = context.apiSuccess(MODULE, '推送预装并创建 PDA 任务', pushResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '推送后计划进入 LOADING 且版本递增', pushResponse,
|
||||
pushed?.status === 'LOADING' && Number(pushed?.version) === Number(assigned.version) + 1,
|
||||
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
|
||||
if (pushed?.status !== 'LOADING') return
|
||||
|
||||
const pushReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
|
||||
const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '推送重放保持同一 LOADING 计划', pushReplayResponse,
|
||||
id(pushReplay?.id) === planId && pushReplay?.status === 'LOADING'
|
||||
&& Number(pushReplay?.version) === Number(pushed.version),
|
||||
`planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const preloadPageResponse = await context.request('GET',
|
||||
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
|
||||
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '预装清单包含本轮 LOADING 计划', preloadPageResponse,
|
||||
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'LOADING'),
|
||||
`records=${records(preloadPage).length}`, OPTIONS)
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS)
|
||||
const taskId = id(task?.id)
|
||||
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
|
||||
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
|
||||
Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING'
|
||||
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
|
||||
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
|
||||
if (!taskId || !pickItem?.id) return
|
||||
|
||||
const wrongWarehouseResponse = await context.request('GET',
|
||||
`${PDA}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=1`)
|
||||
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
|
||||
failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS)
|
||||
|
||||
const stalePickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, {
|
||||
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1,
|
||||
operationId: `${context.marker}-pda-stale`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, 'PDA 旧明细版本拣货返回冲突', stalePickResponse,
|
||||
stalePickResponse.http === 200 && ['A0409', '409'].includes(codeOf(stalePickResponse)),
|
||||
`HTTP=${stalePickResponse.http}, code=${codeOf(stalePickResponse)}`, OPTIONS)
|
||||
|
||||
const pickPayload = {
|
||||
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version),
|
||||
operationId: `${context.marker}-pda-pick`.slice(0, 128),
|
||||
}
|
||||
const pickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
|
||||
const picked = context.apiSuccess(MODULE, '执行本轮 PDA 全量拣货', pickResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '最后一箱拣货后 PDA 任务进入 COMPLETED', pickResponse,
|
||||
picked?.taskStatus === 'COMPLETED'
|
||||
&& picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
|
||||
`taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const pickReplayResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
|
||||
const pickReplay = context.apiSuccess(MODULE, 'PDA operationId 幂等重放', pickReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'PDA 重放保持已完成任务和拣货数', pickReplayResponse,
|
||||
pickReplay?.taskStatus === 'COMPLETED'
|
||||
&& pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
|
||||
`taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const finalPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
||||
const finalPlan = context.apiSuccess(MODULE, '回查柜次装柜计划终态', finalPlanResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜计划进入 COMPLETED 且明细已冻结', finalPlanResponse,
|
||||
finalPlan?.status === 'COMPLETED' && finalPlan?.items?.length === 1 && Boolean(finalPlan.items[0]?.frozenAt),
|
||||
`status=${finalPlan?.status || 'missing'}, frozen=${Boolean(finalPlan?.items?.[0]?.frozenAt)}`, OPTIONS)
|
||||
|
||||
const confirmedPageResponse = await context.request('GET',
|
||||
`${FIRST_LEG}/loading-lists?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
|
||||
const confirmedPage = context.apiSuccess(MODULE, '查询完成装柜清单', confirmedPageResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '完成清单分页包含本轮计划', confirmedPageResponse,
|
||||
records(confirmedPage).some((item) => id(item.planId) === planId && item.status === 'COMPLETED'),
|
||||
`records=${records(confirmedPage).length}`, OPTIONS)
|
||||
|
||||
const confirmedDetailResponse = await context.request('GET',
|
||||
`${FIRST_LEG}/loading-lists/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const confirmedDetail = context.apiSuccess(MODULE, '查询完成装柜清单详情', confirmedDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜清单明细、客户归属、冻结和 PDA 历史完整', confirmedDetailResponse,
|
||||
id(confirmedDetail?.summary?.planId) === planId && confirmedDetail?.summary?.status === 'COMPLETED'
|
||||
&& confirmedDetail?.items?.some?.((item) => id(item.inboundId) === inboundId
|
||||
&& id(item.accountId) === tenantId && Boolean(item.frozenAt))
|
||||
&& confirmedDetail?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'
|
||||
&& Boolean(item.operatorName) && Boolean(item.occurredAt)),
|
||||
`items=${confirmedDetail?.items?.length ?? 'n/a'}, histories=${confirmedDetail?.histories?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const immutableResponse = await request(context, 'PATCH',
|
||||
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
{ containerNo: `MUTATE-${suffix}`.slice(0, 64), voyageNo: 'MUTATE', expectedVersion: Number(finalPlan?.version), operationReason: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-completed-mutate`.slice(0, 128) })
|
||||
context.addStep(MODULE, '完成装柜后运输信息不可变', immutableResponse,
|
||||
immutableResponse.http === 200 && ['A0409', '409'].includes(codeOf(immutableResponse)),
|
||||
`HTTP=${immutableResponse.http}, code=${codeOf(immutableResponse)}`, OPTIONS)
|
||||
|
||||
context.createdResources.phase3ContainerLoading = {
|
||||
marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId,
|
||||
loadingPlanId: planId, pdaTaskId: taskId, finalStatus: 'COMPLETED',
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,31 @@ const TRUCK = '/api/platform/v1/truck-appointments'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method, path, http: response.status, body: payload,
|
||||
raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const ocean = context.createdResources?.oceanBooking
|
||||
const containerFlow = context.createdResources?.phase3ContainerLoading
|
||||
const containerFixture = context.createdResources?.orchestrationFixtures?.phase3Container
|
||||
if (!ocean?.id || ocean.finalStatus !== 'RELEASED') {
|
||||
context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false,
|
||||
'本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS)
|
||||
@@ -20,9 +42,39 @@ export async function run(context) {
|
||||
const customerId = id(oceanDetail?.oceanBooking?.customerId)
|
||||
context.assert(MODULE, '跨服务链路订舱客户归属有效', oceanResponse, Boolean(customerId), `customerId=${customerId || 'missing'}`, OPTIONS)
|
||||
if (!customerId) return
|
||||
const linkContainer = containerFlow?.finalStatus === 'COMPLETED'
|
||||
&& id(containerFlow?.tenantId) === customerId
|
||||
&& Boolean(containerFlow?.containerBookingId)
|
||||
&& Boolean(containerFlow?.loadingPlanId)
|
||||
let oceanVersion = Number(oceanDetail?.oceanBooking?.version)
|
||||
if (containerFlow && !linkContainer) {
|
||||
context.addStep(MODULE, '本轮柜次与订舱客户归属可串联', oceanResponse, false,
|
||||
`oceanCustomerId=${customerId}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}, containerStatus=${containerFlow?.finalStatus || 'missing'}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
if (linkContainer) {
|
||||
const requirement = oceanDetail?.containerRequirements?.find?.((item) => item?.containerType === '40HQ')
|
||||
context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', oceanResponse,
|
||||
Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS)
|
||||
if (!requirement?.id || !Number.isInteger(oceanVersion)) return
|
||||
const allocationResponse = await request(context, 'POST', `${OCEAN}/${ocean.id}/allocations`, {
|
||||
requirementId: id(requirement.id), containerBookingId: id(containerFlow.containerBookingId), expectedVersion: oceanVersion,
|
||||
}, { 'Idempotency-Key': `${context.marker}-flow-container-allocation`.slice(0, 128) })
|
||||
const allocated = context.apiSuccess(MODULE, '将本轮柜次分配至已放舱订舱', allocationResponse, '', OPTIONS)
|
||||
oceanVersion = Number(allocated?.oceanBooking?.version)
|
||||
context.assert(MODULE, '订舱分配保留柜次关联并递增版本', allocationResponse,
|
||||
allocated?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId))
|
||||
&& Number.isInteger(oceanVersion),
|
||||
`containerBookingId=${id(containerFlow.containerBookingId)}, version=${oceanVersion}`, OPTIONS)
|
||||
}
|
||||
const payload = {
|
||||
outboundOrderId: null, oceanBookingId: ocean.id, containerBookingId: null, loadingPlanId: null,
|
||||
warehouseId: id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
|
||||
outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null,
|
||||
oceanBookingId: ocean.id,
|
||||
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
|
||||
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
|
||||
warehouseId: linkContainer
|
||||
? id(containerFlow.warehouseId)
|
||||
: id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
|
||||
customerId, customerName: `服务中心链路客户-${suffix}`, carrierId: null, carrierName: `链路承运商-${suffix}`,
|
||||
fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008',
|
||||
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
|
||||
@@ -31,23 +83,40 @@ export async function run(context) {
|
||||
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, receivableAmount: 2,
|
||||
currency: 'RMB', remark: context.marker, expectedVersion: 0,
|
||||
}
|
||||
if (linkContainer) {
|
||||
const missingContainerResponse = await context.request('POST', TRUCK, {
|
||||
...payload, containerBookingId: null, remark: `${context.marker}-missing-container`,
|
||||
})
|
||||
context.addStep(MODULE, '拖车关联装柜计划时缺失柜次被拒绝', missingContainerResponse,
|
||||
missingContainerResponse.http === 200 && codeOf(missingContainerResponse) !== '200',
|
||||
`HTTP=${missingContainerResponse.http}, code=${codeOf(missingContainerResponse)}`, OPTIONS)
|
||||
}
|
||||
const response = await context.request('POST', TRUCK, payload)
|
||||
const appointment = context.apiSuccess(MODULE, '创建关联海运订舱的拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS)
|
||||
const appointmentId = id(appointment?.id)
|
||||
context.assert(MODULE, '拖车预约关联订舱 ID、版本和审计字段完整', response,
|
||||
context.assert(MODULE, '拖车预约关联订舱、柜次、装柜计划、版本和审计字段完整', response,
|
||||
Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version))
|
||||
&& (!linkContainer || id(appointment?.containerBookingId) === id(containerFlow.containerBookingId)
|
||||
&& id(appointment?.loadingPlanId) === id(containerFlow.loadingPlanId))
|
||||
&& Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
|
||||
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
|
||||
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!appointmentId) return
|
||||
context.createdResources.serviceCenterFlow = { appointmentId, oceanBookingId: ocean.id, marker: context.marker, finalStatus: 'CANCELLED' }
|
||||
context.createdResources.serviceCenterFlow = {
|
||||
appointmentId, oceanBookingId: ocean.id,
|
||||
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
|
||||
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
|
||||
marker: context.marker, finalStatus: 'CANCELLED',
|
||||
}
|
||||
const action = await context.request('POST', `${TRUCK}/${appointmentId}/cancel`, { expectedVersion: Number(appointment.version), reason: context.marker })
|
||||
const cancelled = context.apiSuccess(MODULE, '取消跨服务链路拖车预约', action, '', OPTIONS)
|
||||
context.assert(MODULE, '跨服务链路安全终态为 CANCELLED', action, cancelled?.status === 'CANCELLED', `status=${cancelled?.status || 'missing'}`, OPTIONS)
|
||||
const detail = await context.request('GET', `${TRUCK}/${appointmentId}`)
|
||||
const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS)
|
||||
context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail,
|
||||
final?.status === 'CANCELLED' && id(final?.oceanBookingId) === id(ocean.id),
|
||||
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}`, OPTIONS)
|
||||
final?.status === 'CANCELLED' && id(final?.oceanBookingId) === id(ocean.id)
|
||||
&& (!linkContainer || id(final?.containerBookingId) === id(containerFlow.containerBookingId)
|
||||
&& id(final?.loadingPlanId) === id(containerFlow.loadingPlanId)),
|
||||
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
|
||||
|
||||
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
|
||||
const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS)
|
||||
|
||||
Reference in New Issue
Block a user