const MODULE = 'manual-state-loading-pda' const OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true } const CONTAINERS = '/api/platform/v1/containers' const SMART = '/api/platform/v1/first-leg/smart-loading' const LOADING = '/api/platform/v1/first-leg' const PDA = `${LOADING}/pda-loading` const CONTAINER_STATUSES = ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'] const PRELOAD_STATUSES = ['EDITING', 'LOADING'] const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED'] function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } function suffix(context, label) { return `${String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-10)}${label}`.slice(-20) } function operationId(context, label) { return `${context.marker}-manual-loading-${label}-${crypto.randomUUID()}`.slice(0, 128) } async function post(context, path, body, label) { return context.request('POST', path, body, context.token, { 'Idempotency-Key': operationId(context, label) }) } async function uploadDocument(context, path, expectedVersion, operationReason, label) { const form = new FormData() form.append('file', new Blob([`${context.marker}-${label}`], { type: 'text/plain' }), `${label}.txt`) form.append('expectedVersion', String(expectedVersion)) if (operationReason) form.append('operationReason', operationReason) try { const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, 'Idempotency-Key': operationId(context, `${label}-upload`), }, body: form, signal: AbortSignal.timeout(60_000), }) const raw = await response.text() let body = null try { body = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } return { method: 'POST', path, http: response.status, body, raw: body ? null : raw.slice(0, 500), requestId: body?.requestId || response.headers.get('x-request-id'), traceId: body?.traceId || response.headers.get('x-trace-id') } } catch (error) { return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } async function prepareInbound(context, label) { const originalMarker = context.marker const fixtureMarker = `${originalMarker}-MAN-${label}` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }])) const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(context.context, key), value: context.context[key] }])) try { context.marker = fixtureMarker const orders = await import(new URL('./orders.mjs', import.meta.url)) await orders.run(context) const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) await measurement.run(context) const measured = context.createdResources.inboundMeasurement const order = context.createdResources.orders if (!measured?.inboundId || !measured?.tenantId) throw new Error(`${label} 未生成独立 WAIT_SPLIT 入库单`) return { marker: fixtureMarker, tenantId: id(measured.tenantId), inboundId: id(measured.inboundId), orderId: id(order?.orderId) } } finally { context.marker = originalMarker for (const [key, snapshot] of createdSnapshot) { if (snapshot.present) context.createdResources[key] = snapshot.value else delete context.createdResources[key] } for (const [key, snapshot] of contextSnapshot) { if (snapshot.present) context.context[key] = snapshot.value else delete context.context[key] } } } async function readInbound(context, fixture) { const response = await context.request('GET', `/api/platform/inbounds/${fixture.inboundId}?tenantId=${encodeURIComponent(fixture.tenantId)}`) const value = context.apiSuccess(MODULE, '读取人工装柜独立入库单', response, '', OPTIONS) const warehouseId = id(value?.summary?.warehouseId) context.assert(MODULE, '人工装柜来源入库单可独立排柜', response, value?.summary?.processStatus === 'WAIT_SPLIT' && value?.summary?.measureReviewStatus === 'APPROVED' && Boolean(warehouseId), `inboundId=${fixture.inboundId}, status=${value?.summary?.processStatus || 'missing'}`, OPTIONS) return { ...fixture, detail: value, warehouseId } } async function createConfirmedContainer(context, source, label) { const value = suffix(context, label) const summary = source.detail.summary const draft = { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', channelCode: summary.channelCode, warehouseId: source.warehouseId, warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`, bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: `${context.marker}-${label}`, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${value}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }], containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }], } const createdResponse = await post(context, '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: draft }, `${label}-booking-create`) const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS) const bookingId = id(created?.oceanBooking?.id) const submittedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/submit`, { expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker }, `${label}-booking-submit`) const submitted = context.apiSuccess(MODULE, `${label} 提交订舱`, submittedResponse, '', OPTIONS) const confirmedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/confirm`, { expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, `${label}-booking-confirm`) const confirmed = context.apiSuccess(MODULE, `${label} 确认订舱`, confirmedResponse, '', OPTIONS) const containerId = id(confirmed?.containerAllocations?.[0]?.containerBookingId) if (!containerId) throw new Error(`${label} 确认订舱未生成柜次`) return containerId } async function createPlanTask(context, label) { const source = await readInbound(context, await prepareInbound(context, label)) const containerId = await createConfirmedContainer(context, source, label) const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`) const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS) const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === source.inboundId && Number(item.availablePackageCount) > 0) : null if (!candidate?.inboundDetailId) throw new Error(`${label} 独立入库单未进入同渠道排柜候选`) const planResponse = await context.request('POST', `${SMART}/plans`, { bookingId: containerId, remark: `${context.marker}-${label}` }) let plan = context.apiSuccess(MODULE, `${label} 创建智能排柜计划`, planResponse, '', OPTIONS) const assignResponse = await context.request('POST', `${SMART}/plans/${plan.id}/assignments`, { inboundId: source.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`), }) plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS) const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, { expectedVersion: Number(plan.version) }, `${label}-push`) plan = context.apiSuccess(MODULE, `${label} 推送预装`, pushResponse, '', OPTIONS) const detailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) const container = context.apiSuccess(MODULE, `${label} 读取柜次`, detailResponse, '', OPTIONS) const containerNo = `MAN-${suffix(context, label)}`.slice(0, 64) const transportResponse = await context.request('PATCH', `${CONTAINERS}/${containerId}/transport`, { containerNo, voyageNo: `V-${suffix(context, label)}`.slice(0, 64), version: Number(container.version), reason: `${context.marker} 人工状态样本`, }) context.apiSuccess(MODULE, `${label} 写入真实集装箱号并开放 PDA`, transportResponse, '', OPTIONS) const taskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${plan.id}&warehouseId=${source.warehouseId}`) const task = context.apiSuccess(MODULE, `${label} 读取 PDA 任务`, taskResponse, '', OPTIONS) if (!task?.id || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成`) return { source, containerId, containerNo, plan, task } } async function claim(context, chain, label) { const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/claim`, { expectedAssignmentVersion: Number(chain.task?.assignment?.version || 0), operationId: operationId(context, `${label}-claim`), }) chain.task = context.apiSuccess(MODULE, `${label} 领取 PDA 任务`, response, '', OPTIONS) return chain } async function pickAll(context, chain, label) { const snapshotResponse = await context.request('GET', `${PDA}/tasks/${chain.task.id}/inventory-snapshot?warehouseId=${chain.source.warehouseId}`) const snapshot = context.apiSuccess(MODULE, `${label} 获取 PDA 库存快照`, snapshotResponse, '', OPTIONS) const response = await post(context, `${PDA}/tasks/${chain.task.id}/batch`, { warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.task.loadingPlanId), containerBookingId: chain.containerId, expectedVersion: Number(chain.task.version), inventorySnapshotToken: snapshot.snapshotToken, items: chain.task.items.map((item) => ({ itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), reason: null })), }, `${label}-batch`) chain.task = context.apiSuccess(MODULE, `${label} 完成 PDA 拣货`, response, '', OPTIONS) return chain } async function complete(context, chain, label) { const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/complete`, { expectedTaskVersion: Number(chain.task.version), operationId: operationId(context, `${label}-complete`), remark: `${context.marker} 人工完成状态样本`, }) chain.task = context.apiSuccess(MODULE, `${label} 人工确认装柜完成`, response, '', OPTIONS) return chain } async function verifyContainer(context, status, fixture) { const response = await context.request('GET', `${CONTAINERS}?status=${status}&page=1&size=100`) const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS) context.assert(MODULE, `${status} 货柜样本保留`, response, records(page).some((item) => id(item.id) === fixture.id && item.planStatus === status), `id=${fixture.id}`, OPTIONS) } async function verifyPlan(context, status, fixture) { const response = await context.request('GET', `${LOADING}/loading-plans?status=${status}&warehouseId=${fixture.warehouseId}&page=1&size=100`) const page = context.apiSuccess(MODULE, `${status} 预装清单可筛选`, response, '', OPTIONS) context.assert(MODULE, `${status} 预装清单样本保留`, response, records(page).some((item) => id(item.planId) === fixture.id && item.status === status), `planId=${fixture.id}`, OPTIONS) const detailResponse = await context.request('GET', `${LOADING}/loading-plans/${fixture.id}?warehouseId=${fixture.warehouseId}`) const detail = context.apiSuccess(MODULE, `${status} 预装清单详情可打开`, detailResponse, '', OPTIONS) context.assert(MODULE, `${status} 预装清单详情身份正确`, detailResponse, id(detail?.summary?.planId) === fixture.id && detail?.summary?.status === status, `planId=${fixture.id}, actual=${detail?.summary?.status || 'missing'}`, OPTIONS) } async function verifyTask(context, status, fixture) { const response = await context.request('GET', `${PDA}/tasks/${fixture.id}?warehouseId=${fixture.warehouseId}`) const task = context.apiSuccess(MODULE, `${status} PDA 任务可打开`, response, '', OPTIONS) context.assert(MODULE, `${status} PDA 任务样本保留`, response, id(task?.id) === fixture.id && task?.workflowStatus === status, `taskId=${fixture.id}, actual=${task?.workflowStatus || 'missing'}`, OPTIONS) } function fixture(value, status, detailPath, extra = {}) { return { id: id(value), status, detailPath, ...extra } } async function completeSimpleDocuments(context, chain, matrix) { const reason = `${context.marker} 人工页面动作验收` const clearancePath = `${CONTAINERS}/${chain.containerId}/clearance` const clearanceUpload = await uploadDocument(context, `${clearancePath}/document/upload`, 0, reason, 'container-clearance') const clearanceDocument = context.apiSuccess(MODULE, '货柜公开接口上传目的港清关资料', clearanceUpload, '', OPTIONS) context.assert(MODULE, '货柜清关上传返回真实文件事实', clearanceUpload, Boolean(clearanceDocument?.id) && Boolean(clearanceDocument?.fileId) && clearanceDocument?.documentType === 'DESTINATION_CLEARANCE' && /^[0-9a-f]{64}$/i.test(String(clearanceDocument?.fileHash || '')) && !clearanceDocument?.completedAt, `documentId=${id(clearanceDocument?.id) || 'missing'}, version=${clearanceDocument?.version ?? 'missing'}`, OPTIONS) if (!clearanceDocument?.id || !clearanceDocument?.fileId || !Number.isInteger(Number(clearanceDocument?.version))) return const clearanceCompleteResponse = await post(context, `${clearancePath}/complete`, { expectedVersion: Number(clearanceDocument?.version), operationReason: reason, }, 'container-clearance-complete') const clearanceCompleted = context.apiSuccess(MODULE, '货柜点击完成目的港清关', clearanceCompleteResponse, '', OPTIONS) context.assert(MODULE, '货柜清关完成事实可回读', clearanceCompleteResponse, id(clearanceCompleted?.id) === id(clearanceDocument?.id) && Boolean(clearanceCompleted?.completedAt) && Number(clearanceCompleted?.version) === Number(clearanceDocument?.version) + 1, `documentId=${id(clearanceCompleted?.id) || 'missing'}, completedAt=${clearanceCompleted?.completedAt || 'missing'}`, OPTIONS) const customsPath = `${LOADING}/loading-lists/${chain.plan.id}/customs` const warehouse = `warehouseId=${encodeURIComponent(chain.source.warehouseId)}` const customsUpload = await uploadDocument(context, `${customsPath}/document/upload?${warehouse}`, 0, reason, 'loading-customs') const customsDocument = context.apiSuccess(MODULE, '装柜清单公开接口上传出口报关文档', customsUpload, '', OPTIONS) context.assert(MODULE, '装柜清单报关上传返回真实文件事实', customsUpload, Boolean(customsDocument?.id) && Boolean(customsDocument?.fileId) && customsDocument?.documentType === 'CUSTOMS_DECLARATION' && /^[0-9a-f]{64}$/i.test(String(customsDocument?.fileHash || '')) && !customsDocument?.completedAt, `documentId=${id(customsDocument?.id) || 'missing'}, version=${customsDocument?.version ?? 'missing'}`, OPTIONS) if (!customsDocument?.id || !customsDocument?.fileId || !Number.isInteger(Number(customsDocument?.version))) return const customsCompleteResponse = await post(context, `${customsPath}/complete?${warehouse}`, { expectedVersion: Number(customsDocument?.version), operationReason: reason, }, 'loading-customs-complete') const customsCompleted = context.apiSuccess(MODULE, '装柜清单点击完成出口报关', customsCompleteResponse, '', OPTIONS) context.assert(MODULE, '装柜清单报关完成事实可回读', customsCompleteResponse, id(customsCompleted?.id) === id(customsDocument?.id) && Boolean(customsCompleted?.completedAt) && Number(customsCompleted?.version) === Number(customsDocument?.version) + 1, `documentId=${id(customsCompleted?.id) || 'missing'}, completedAt=${customsCompleted?.completedAt || 'missing'}`, OPTIONS) matrix.pages.containerLoading.actions = { clearance: fixture(chain.containerId, 'COMPLETED', `/container-loading/${chain.containerId}/smart-loading`, { documentId: id(clearanceCompleted?.id), documentVersion: Number(clearanceCompleted?.version), completedAt: clearanceCompleted?.completedAt || null, }) } matrix.pages.loadingLists.actions = { customsDeclaration: fixture(chain.plan.id, 'COMPLETED', `/loading-lists?planId=${chain.plan.id}&warehouseId=${chain.source.warehouseId}`, { warehouseId: chain.source.warehouseId, documentId: id(customsCompleted?.id), documentVersion: Number(customsCompleted?.version), completedAt: customsCompleted?.completedAt || null, }) } } export async function run(context, matrix = context.createdResources.manualStateMatrix) { matrix.pages.containerLoading = { requiredStatuses: CONTAINER_STATUSES, requiredActions: ['clearance'], fixtures: {}, actions: {} } matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} } matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], requiredActions: ['customsDeclaration'], fixtures: {}, actions: {} } matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} } const waitingSource = await readInbound(context, await prepareInbound(context, 'WAIT')) const waitingContainerId = await createConfirmedContainer(context, waitingSource, 'WAIT') matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainerId, 'WAIT_LOADING', `/container-loading?containerId=${waitingContainerId}`) const editing = await createPlanTask(context, 'EDIT') const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS') const awaiting = await pickAll(context, await claim(context, await createPlanTask(context, 'REVIEW'), 'REVIEW'), 'REVIEW') const completed = await complete(context, await pickAll(context, await claim(context, await createPlanTask(context, 'DONE'), 'DONE'), 'DONE'), 'DONE') const chains = { EDITING: editing, LOADING: inProgress, COMPLETED: completed } for (const [status, chain] of Object.entries(chains)) { matrix.pages.containerLoading.fixtures[status] = fixture(chain.containerId, status, `/container-loading/${chain.containerId}/smart-loading`, { planId: id(chain.plan.id) }) } for (const [status, chain] of Object.entries({ EDITING: editing, LOADING: inProgress })) { matrix.pages.preloadPlans.fixtures[status] = fixture(chain.plan.id, status, `/loading-plans/${chain.plan.id}?warehouseId=${chain.source.warehouseId}`, { warehouseId: chain.source.warehouseId }) } matrix.pages.loadingLists.fixtures.COMPLETED = fixture(completed.plan.id, 'COMPLETED', `/loading-lists?planId=${completed.plan.id}&warehouseId=${completed.source.warehouseId}`, { warehouseId: completed.source.warehouseId }) const taskChains = { UNASSIGNED: editing, IN_PROGRESS: inProgress, AWAITING_REVIEW: awaiting, COMPLETED: completed } for (const [status, chain] of Object.entries(taskChains)) { matrix.pages.pdaTasks.fixtures[status] = fixture(chain.task.id, status, `/pages/picking/picking?taskId=${chain.task.id}&warehouseId=${chain.source.warehouseId}`, { warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.plan.id) }) } for (const [status, value] of Object.entries(matrix.pages.containerLoading.fixtures)) await verifyContainer(context, status, value) for (const [status, value] of Object.entries(matrix.pages.preloadPlans.fixtures)) await verifyPlan(context, status, value) for (const [status, value] of Object.entries(matrix.pages.pdaTasks.fixtures)) await verifyTask(context, status, value) const listFixture = matrix.pages.loadingLists.fixtures.COMPLETED const listResponse = await context.request('GET', `${LOADING}/loading-lists?warehouseId=${listFixture.warehouseId}&page=1&size=100`) const list = context.apiSuccess(MODULE, 'COMPLETED 装柜清单可从列表打开', listResponse, '', OPTIONS) context.assert(MODULE, 'COMPLETED 装柜清单样本保留', listResponse, records(list).some((item) => id(item.planId) === listFixture.id && item.status === 'COMPLETED'), `planId=${listFixture.id}`, OPTIONS) const listDetailResponse = await context.request('GET', `${LOADING}/loading-lists/${listFixture.id}?warehouseId=${listFixture.warehouseId}`) const listDetail = context.apiSuccess(MODULE, 'COMPLETED 装柜清单详情可打开', listDetailResponse, '', OPTIONS) context.assert(MODULE, 'COMPLETED 装柜清单详情身份正确', listDetailResponse, id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED', `planId=${listFixture.id}, actual=${listDetail?.summary?.status || 'missing'}`, OPTIONS) await completeSimpleDocuments(context, completed, matrix) }