feat(firstleg): converge phase3 loading models and pda
This commit is contained in:
@@ -20,7 +20,8 @@ async function createBooking(context, suffix, key) { return request(context, 'PO
|
||||
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
|
||||
|
||||
export async function run(context) {
|
||||
const containerFlow = context.createdResources?.phase3ContainerLoading
|
||||
const containerFlow = context.createdResources?.phase3Loading
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
||||
const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
|
||||
if (missing(list) || list.http === 0) {
|
||||
@@ -58,7 +59,7 @@ export async function run(context) {
|
||||
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
|
||||
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
|
||||
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
|
||||
if (containerFlow?.finalStatus === 'COMPLETED') {
|
||||
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
|
||||
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
|
||||
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
|
||||
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS)
|
||||
|
||||
@@ -174,6 +174,7 @@ export async function run(context) {
|
||||
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'
|
||||
&& task?.workflowStatus === 'UNASSIGNED'
|
||||
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
|
||||
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
|
||||
if (!taskId || !pickItem?.id) return
|
||||
@@ -183,6 +184,15 @@ export async function run(context) {
|
||||
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
|
||||
failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS)
|
||||
|
||||
const claimResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
|
||||
expectedAssignmentVersion: 0, operationId: `${context.marker}-pda-claim`.slice(0, 128),
|
||||
})
|
||||
const claimed = context.apiSuccess(MODULE, '领取本轮柜次 PDA 任务', claimResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'PDA 任务只分配给当前登录作业员', claimResponse,
|
||||
claimed?.workflowStatus === 'IN_PROGRESS' && id(claimed?.assignment?.assigneeId)
|
||||
&& Number(claimed?.assignment?.version) === 0,
|
||||
`workflow=${claimed?.workflowStatus || 'missing'}, assigneeId=${id(claimed?.assignment?.assigneeId) || 'missing'}`, 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),
|
||||
@@ -197,18 +207,28 @@ export async function run(context) {
|
||||
}
|
||||
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'
|
||||
context.assert(MODULE, '最后一箱拣货后 PDA 任务等待复核', pickResponse,
|
||||
picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW'
|
||||
&& picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
|
||||
`taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS)
|
||||
`taskStatus=${picked?.taskStatus || 'missing'}, workflow=${picked?.workflowStatus || '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'
|
||||
context.assert(MODULE, 'PDA 重放保持待复核任务和拣货数', pickReplayResponse,
|
||||
pickReplay?.taskStatus === 'LOADING' && pickReplay?.workflowStatus === 'AWAITING_REVIEW'
|
||||
&& pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
|
||||
`taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const reviewResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/review`, {
|
||||
expectedTaskVersion: Number(picked?.version), operationId: `${context.marker}-pda-review`.slice(0, 128),
|
||||
remark: context.marker,
|
||||
})
|
||||
const reviewed = context.apiSuccess(MODULE, '复核本轮柜次 PDA 任务', reviewResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '复核后任务与柜次计划进入真实完成态', reviewResponse,
|
||||
reviewed?.taskStatus === 'COMPLETED' && reviewed?.workflowStatus === 'COMPLETED'
|
||||
&& reviewed?.review?.status === 'APPROVED' && Boolean(reviewed?.review?.reviewedBy),
|
||||
`taskStatus=${reviewed?.taskStatus || 'missing'}, review=${reviewed?.review?.status || 'missing'}`, OPTIONS)
|
||||
|
||||
const finalPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
||||
const finalPlan = context.apiSuccess(MODULE, '回查柜次装柜计划终态', finalPlanResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜计划进入 COMPLETED 且明细已冻结', finalPlanResponse,
|
||||
@@ -243,6 +263,7 @@ export async function run(context) {
|
||||
|
||||
context.createdResources.phase3ContainerLoading = {
|
||||
marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId,
|
||||
loadingPlanId: planId, pdaTaskId: taskId, finalStatus: 'COMPLETED',
|
||||
loadingPlanId: planId, pdaTaskId: taskId, pdaReviewVersion: reviewed?.review?.version,
|
||||
finalStatus: 'COMPLETED',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,13 +68,16 @@ async function detail(context, caseId) {
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.orchestrationFixtures?.phase3Container
|
||||
const container = context.createdResources?.phase3ContainerLoading
|
||||
const fixture = context.createdResources?.orchestrationFixtures?.inboundMeasurement
|
||||
|| context.createdResources?.orchestrationFixtures?.phase3Container
|
||||
const container = context.createdResources?.phase3Loading
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
const tenantId = id(fixture?.tenantId)
|
||||
const orderId = id(fixture?.orderId)
|
||||
const inboundId = id(fixture?.inboundId)
|
||||
const containerBookingId = id(container?.containerBookingId)
|
||||
if (!tenantId || !orderId || !inboundId || !containerBookingId || container?.finalStatus !== 'COMPLETED') {
|
||||
if (!tenantId || !orderId || !inboundId || !containerBookingId
|
||||
|| !['COMPLETED', 'LOADED'].includes(container?.finalStatus)) {
|
||||
context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false,
|
||||
`tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
|
||||
return
|
||||
|
||||
@@ -1,10 +1,49 @@
|
||||
import { registerPendingContract } from './_phase3-pending-contract.mjs'
|
||||
|
||||
const MODULE = 'phase3-loading-model-bridge'
|
||||
const OPTIONS = { scenario: 'parallel-loading-models', deploymentBlocker: true }
|
||||
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') }
|
||||
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) {
|
||||
registerPendingContract(context, {
|
||||
module: 'phase3-loading-model-bridge', scenario: 'parallel-loading-models', name: '入库单级预装与柜次级智能装柜串联',
|
||||
reason: 'fl_preload_plan 按入库单推进 LOADING/LOADED,fl_loading_plan 按柜次推进 EDITING/LOADING/COMPLETED;两套公开 API 不会互相创建、绑定或校验终态', category: 'contract-gap',
|
||||
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增两套装柜模型关联键与联动契约',
|
||||
assertions: ['同一 inbound 与柜次计划双向可查', '任一侧版本冲突不产生部分更新', '双终态一致后方可进入出仓门禁'],
|
||||
})
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
|
||||
const loadingPlanId = id(fixture?.loadingPlanId)
|
||||
if (!tenantId || !inboundId || !loadingPlanId || fixture?.marker !== context.marker) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联',
|
||||
'本轮未取得同一 marker 的预装/柜次计划关联 fixture,未使用历史资源', 'fixture',
|
||||
{ activation: { state: 'pending-fixture', assertions: ['双向关联可查', '双版本冲突原子回滚', '终态门禁一致'] } })
|
||||
return
|
||||
}
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联',
|
||||
'桥接契约、SQL和真实断言已完成,待目标提交部署并取得本模块真实 HTTP PASS 后删除此项',
|
||||
'deployment-validation-pending', { activation: { state: 'pending-deployment-validation' } })
|
||||
const inboundPath = `/api/platform/v1/inbounds/${inboundId}/preload/loading-plans?tenantId=${encodeURIComponent(tenantId)}`
|
||||
const inboundResponse = await request(context, 'GET', inboundPath)
|
||||
const inboundLinks = context.apiSuccess(MODULE, '从入库侧查询预装与柜次关联', inboundResponse, '', OPTIONS)
|
||||
const link = Array.isArray(inboundLinks) ? inboundLinks.find((item) => id(item.loadingPlanId) === loadingPlanId) : null
|
||||
context.assert(MODULE, '入库侧关联只返回本轮柜次和客户隔离键', inboundResponse,
|
||||
Boolean(link) && id(link.inboundId) === inboundId && id(link.tenantId) === tenantId
|
||||
&& Number(link.allocatedPackageCount) > 0, `links=${inboundLinks?.length ?? 'n/a'}`, OPTIONS)
|
||||
const planResponse = await request(context, 'GET', `/api/platform/v1/first-leg/smart-loading/plans/${loadingPlanId}/preloads`)
|
||||
const planLinks = context.apiSuccess(MODULE, '从柜次侧查询反向预装关联', planResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '柜次侧反查到同一预装计划', planResponse,
|
||||
Array.isArray(planLinks) && planLinks.some((item) => id(item.inboundId) === inboundId
|
||||
&& id(item.preloadPlanId) === id(fixture.preloadPlanId)), `links=${planLinks?.length ?? 'n/a'}`, OPTIONS)
|
||||
const forged = await request(context, 'POST',
|
||||
`/api/platform/v1/inbounds/${inboundId}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedPreloadVersion: 0, expectedLoadingPlanVersion: 0 },
|
||||
{ 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
|
||||
context.addStep(MODULE, '桥接同幂等键不同版本请求拒绝且不产生部分更新', forged, failure(forged),
|
||||
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,52 @@ export async function run(context) {
|
||||
|
||||
const base = `/api/platform/v1/inbounds/${inboundId}`
|
||||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
||||
const warehouseId = id(initial?.summary?.warehouseId)
|
||||
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
|
||||
const pdaBase = '/api/platform/v1/first-leg/pda-loading'
|
||||
const candidateResponse = await context.request('GET', `${smartLoadingBase}/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?.accountId) === tenantId
|
||||
&& id(candidate?.warehouseId) === warehouseId,
|
||||
`candidate=${Boolean(candidate)}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
|
||||
if (!candidate?.inboundDetailId || !warehouseId) return
|
||||
|
||||
const containerNo = `P3B-${suffix}`
|
||||
const containerResponse = await request(context, 'POST', '/api/platform/v1/containers', {
|
||||
containerNo, cabinetNo: `P3BI-${suffix}`, warehouseId,
|
||||
warehouseName: initial?.summary?.warehouseName || null, billOfLadingNo: `P3BL-${suffix}`,
|
||||
vesselName: `P3-BRIDGE-${suffix}`, voyageNo: `P3V-${suffix}`, containerType: '40HQ',
|
||||
shippingCompanyName: `P3-SHIPPING-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳',
|
||||
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
destinationWarehouseCode: candidate.destinationWarehouseCode || null,
|
||||
destinationWarehouseName: candidate.destinationWarehouseName || null,
|
||||
capacityCbm: 100, maxWeightKg: 100000, 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 container = context.apiSuccess(MODULE, '创建与入库预装串联的柜次', containerResponse, '', OPTIONS)
|
||||
const containerBookingId = id(container?.id)
|
||||
if (!containerBookingId) return
|
||||
|
||||
const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, {
|
||||
bookingId: containerBookingId, remark: context.marker,
|
||||
})
|
||||
const loadingPlan = context.apiSuccess(MODULE, '创建柜次级智能装柜计划', loadingPlanResponse, '', OPTIONS)
|
||||
const loadingPlanId = id(loadingPlan?.id)
|
||||
if (!loadingPlanId) return
|
||||
const assignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
|
||||
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(loadingPlan.version), operationId: `${context.marker}-bridge-assign`.slice(0, 128),
|
||||
})
|
||||
const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse,
|
||||
assignedLoadingPlan?.items?.some?.((item) => id(item.inboundId) === inboundId && Number(item.packageCount) === 1)
|
||||
&& Number(assignedLoadingPlan?.version) === 1,
|
||||
`loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedVersion: 0,
|
||||
items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }],
|
||||
@@ -104,11 +150,31 @@ export async function run(context) {
|
||||
&& replay.body?.data?.preloadPlan?.items?.length === 1,
|
||||
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
const bindResponse = await request(context, 'POST',
|
||||
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedPreloadVersion: Number(smartData.preloadPlan.version),
|
||||
expectedLoadingPlanVersion: Number(assignedLoadingPlan.version),
|
||||
}, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
|
||||
const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse,
|
||||
id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId
|
||||
&& id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1
|
||||
&& Number(link?.preloadVersion) === 1 && Number(link?.loadingPlanVersion) === 2,
|
||||
`linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
|
||||
if (!link?.id) return
|
||||
|
||||
const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
|
||||
{ expectedVersion: Number(link.loadingPlanVersion) },
|
||||
{ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) })
|
||||
const pushedLoadingPlan = context.apiSuccess(MODULE, '桥接后推送柜次计划并生成真实 PDA 任务', pushResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '关联柜次计划进入 LOADING', pushResponse,
|
||||
pushedLoadingPlan?.status === 'LOADING', `status=${pushedLoadingPlan?.status || 'missing'}`, OPTIONS)
|
||||
|
||||
const confirming = await request(context, 'POST', `${base}/preload/confirming?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: Number(smartData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
|
||||
{ expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
|
||||
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
|
||||
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming,
|
||||
confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 1,
|
||||
confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 2,
|
||||
`status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
|
||||
|
||||
@@ -117,7 +183,7 @@ export async function run(context) {
|
||||
const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS)
|
||||
context.assert(MODULE, '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
|
||||
confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED'
|
||||
&& Number(confirmed?.preloadPlan?.version) === 2,
|
||||
&& Number(confirmed?.preloadPlan?.version) === 3,
|
||||
`mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
if (confirmed?.mainStatus !== 'LOADING') return
|
||||
|
||||
@@ -142,13 +208,58 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '未完成 PDA 拣货时拒绝完成装柜', blockedBeforePicking, failure(blockedBeforePicking),
|
||||
`HTTP=${blockedBeforePicking.http}, code=${codeOf(blockedBeforePicking)}`, OPTIONS)
|
||||
|
||||
const canonicalTaskResponse = await context.request('GET',
|
||||
`${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const canonicalTask = context.apiSuccess(MODULE, '读取柜次级 PDA 唯一事实任务', canonicalTaskResponse, '', OPTIONS)
|
||||
const canonicalTaskId = id(canonicalTask?.id)
|
||||
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
|
||||
context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse,
|
||||
Boolean(canonicalTaskId) && id(canonicalTask?.loadingPlanId) === loadingPlanId
|
||||
&& canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id),
|
||||
`taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS)
|
||||
if (!canonicalTaskId || !canonicalItem?.id) return
|
||||
|
||||
const claim = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/claim`, {
|
||||
expectedAssignmentVersion: 0, operationId: `${context.marker}-bridge-claim`.slice(0, 128),
|
||||
})
|
||||
const claimed = context.apiSuccess(MODULE, '领取柜次 PDA 任务', claim, '', OPTIONS)
|
||||
context.assert(MODULE, '柜次 PDA 任务绑定当前作业员', claim,
|
||||
claimed?.workflowStatus === 'IN_PROGRESS' && Boolean(claimed?.assignment?.assigneeId),
|
||||
`workflow=${claimed?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const canonicalPick = await request(context, 'POST',
|
||||
`${pdaBase}/tasks/${canonicalTaskId}/items/${id(canonicalItem.id)}/pick`, {
|
||||
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(canonicalItem.version),
|
||||
operationId: `${context.marker}-bridge-pick`.slice(0, 128),
|
||||
})
|
||||
const pickedCanonical = context.apiSuccess(MODULE, '执行柜次 PDA 明细拣货', canonicalPick, '', OPTIONS)
|
||||
context.assert(MODULE, '柜次 PDA 明细完成后等待复核', canonicalPick,
|
||||
pickedCanonical?.workflowStatus === 'AWAITING_REVIEW' && pickedCanonical?.taskStatus === 'LOADING',
|
||||
`workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, {
|
||||
expectedTaskVersion: Number(pickedCanonical?.version),
|
||||
operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
|
||||
})
|
||||
const reviewedCanonical = context.apiSuccess(MODULE, '复核柜次 PDA 并完成柜次计划', canonicalReview, '', OPTIONS)
|
||||
context.assert(MODULE, '柜次 PDA 复核成为装柜完成真实门禁', canonicalReview,
|
||||
reviewedCanonical?.taskStatus === 'COMPLETED' && reviewedCanonical?.workflowStatus === 'COMPLETED'
|
||||
&& reviewedCanonical?.review?.status === 'APPROVED',
|
||||
`taskStatus=${reviewedCanonical?.taskStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const forgedPickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: 'forged-client-count' },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-forged-start`.slice(0, 128) })
|
||||
context.addStep(MODULE, '入库级 PDA 汇总拒绝客户端伪造数量', forgedPickingStart, failure(forgedPickingStart),
|
||||
`HTTP=${forgedPickingStart.http}, code=${codeOf(forgedPickingStart)}`, OPTIONS)
|
||||
|
||||
const pickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker },
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) })
|
||||
const pickingStarted = context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS)
|
||||
context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart,
|
||||
pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0
|
||||
&& Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 0,
|
||||
&& Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 1,
|
||||
`status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
@@ -240,6 +351,7 @@ export async function run(context) {
|
||||
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
|
||||
context.createdResources.phase3Loading = {
|
||||
tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)),
|
||||
loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId,
|
||||
pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED',
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,12 @@ async function request(context, method, path, body, headers = {}) {
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3ContainerLoading
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
const inboundId = id(fixture?.inboundId)
|
||||
const containerBookingId = id(fixture?.containerBookingId)
|
||||
if (!inboundId || !containerBookingId || fixture?.marker !== context.marker) {
|
||||
if (!inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(fixture?.finalStatus)
|
||||
|| fixture?.marker !== context.marker) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环',
|
||||
'本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture')
|
||||
return
|
||||
|
||||
@@ -1,10 +1,59 @@
|
||||
import { registerPendingContract } from './_phase3-pending-contract.mjs'
|
||||
|
||||
const MODULE = 'phase3-pda-convergence'
|
||||
const OPTIONS = { scenario: 'pda-picking-convergence', deploymentBlocker: true }
|
||||
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') }
|
||||
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) {
|
||||
registerPendingContract(context, {
|
||||
module: 'phase3-pda-convergence', scenario: 'pda-picking-convergence', name: 'PDA 柜次拣货与入库单汇总拣货收敛',
|
||||
reason: '柜次级 /first-leg/pda-loading 和入库单级 /inbounds/{id}/loading/picking 是两套独立任务;当前无领取、作业员分配、复核和统一完成门禁', category: 'contract-gap',
|
||||
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增 PDA 任务领取、分配、复核与汇总契约',
|
||||
assertions: ['任务领取和作业员隔离', '柜次明细与入库汇总数量一致', '复核完成幂等且成为装柜门禁前置'],
|
||||
})
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
|
||||
const taskId = id(fixture?.canonicalPdaTaskId); const containerNo = fixture?.containerNo
|
||||
const warehouseId = id(fixture?.warehouseId)
|
||||
if (!tenantId || !inboundId || !taskId || !containerNo || !warehouseId || fixture?.marker !== context.marker) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛',
|
||||
'本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛',
|
||||
'PDA唯一事实源、作业员隔离、复核和入库聚合真实断言已完成,待目标提交部署并取得真实 HTTP PASS 后删除此项',
|
||||
'deployment-validation-pending', { activation: { state: 'pending-deployment-validation' } })
|
||||
const taskResponse = await request(context, 'GET',
|
||||
`/api/platform/v1/first-leg/pda-loading/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, '回查柜次 PDA 唯一事实任务', taskResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '柜次任务已领取、复核且明细数量一致', taskResponse,
|
||||
id(task?.id) === taskId && task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED'
|
||||
&& Boolean(task?.assignment?.assigneeId) && task?.review?.status === 'APPROVED'
|
||||
&& task?.items?.every?.((item) => Number(item.plannedPackageCount) === Number(item.pickedPackageCount)),
|
||||
`taskStatus=${task?.taskStatus || 'missing'}, workflow=${task?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
const projectionResponse = await request(context, 'GET',
|
||||
`/api/platform/v1/inbounds/${inboundId}/loading/picking?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const projection = context.apiSuccess(MODULE, '查询入库级 PDA 服务端聚合投影', projectionResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '入库投影与柜次事实数量和终态一致', projectionResponse,
|
||||
projection?.status === 'COMPLETED' && Number(projection?.expectedCount) > 0
|
||||
&& Number(projection?.expectedCount) === Number(projection?.pickedCount),
|
||||
`status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS)
|
||||
const operationsResponse = await request(context, 'GET',
|
||||
`/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/operations`)
|
||||
const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '领取和复核操作历史含操作者与时间', operationsResponse,
|
||||
Array.isArray(operations) && ['CLAIM', 'REVIEW'].every((action) => operations.some((item) => item.actionType === action
|
||||
&& Boolean(item.operatorId) && Boolean(item.createdAt))),
|
||||
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
||||
const forged = await request(context, 'POST',
|
||||
`/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' },
|
||||
{ 'Idempotency-Key': `${context.marker}-pda-forged-projection`.slice(0, 128) })
|
||||
context.addStep(MODULE, '入库兼容入口拒绝客户端伪造汇总数量', forged, failure(forged),
|
||||
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -85,8 +85,9 @@ export async function run(context) {
|
||||
|
||||
const ocean = await ensureOceanBooking(context, suffix)
|
||||
if (!ocean?.id) return
|
||||
const flow = context.createdResources?.phase3ContainerLoading
|
||||
const containerBookingId = flow?.finalStatus === 'COMPLETED' && id(flow?.tenantId) === '10001'
|
||||
const flow = context.createdResources?.phase3Loading
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus)
|
||||
? id(flow.containerBookingId) : null
|
||||
const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId
|
||||
? containerBookingId : null
|
||||
|
||||
@@ -8,8 +8,10 @@ function codeOf(response) { return response?.body?.code == null ? 'missing' : St
|
||||
|
||||
export async function run(context) {
|
||||
const ocean = context.createdResources?.oceanBooking
|
||||
const containerFlow = context.createdResources?.phase3ContainerLoading
|
||||
const containerFixture = context.createdResources?.orchestrationFixtures?.phase3Container
|
||||
const containerFlow = context.createdResources?.phase3Loading
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
const containerFixture = context.createdResources?.orchestrationFixtures?.inboundMeasurement
|
||||
|| context.createdResources?.orchestrationFixtures?.phase3Container
|
||||
if (!ocean?.id || ocean.finalStatus !== 'RELEASED') {
|
||||
context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false,
|
||||
'本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS)
|
||||
@@ -22,7 +24,7 @@ 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'
|
||||
const linkContainer = (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED')
|
||||
&& id(containerFlow?.tenantId) === customerId
|
||||
&& Boolean(containerFlow?.containerBookingId)
|
||||
&& Boolean(containerFlow?.loadingPlanId)
|
||||
|
||||
Reference in New Issue
Block a user