diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 68e4197a6..8443cfcd4 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -37,7 +37,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.match(source, /linked\?\.cabinetNo !== simulation\.cabinetNo/) assert.match(source, /iso6346ContainerNumber/) - assert.match(source, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/) + assert.match(source, /shippingCompanyId: id\(carrier\.id\), shippingCompanyName: carrier\.name/) assert.match(source, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/) assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/) const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete')) @@ -57,7 +57,7 @@ test('fails closed when invoked outside the manual matrix wrapper', () => { assert.match(source, /missing=manualStateMatrix\.pages/) }) -test('covers simplified clearance and customs actions through public multipart upload and complete APIs', () => { +test('covers clearance through its public API and keeps customs on the dedicated customs-v1 flow', () => { assert.match(source, /new FormData\(\)/) assert.match(source, /form\.append\('file'/) assert.match(source, /form\.append\('expectedVersion'/) @@ -65,10 +65,9 @@ test('covers simplified clearance and customs actions through public multipart u assert.match(source, /'Idempotency-Key'/) assert.match(source, /clearancePath\}\/document\/upload/) assert.match(source, /clearancePath\}\/complete/) - assert.match(source, /customsPath\}\/document\/upload\?\$\{warehouse\}/) - assert.match(source, /customsPath\}\/complete\?\$\{warehouse\}/) assert.match(source, /matrix\.pages\.containerLoading\.actions/) - assert.match(source, /matrix\.pages\.loadingLists\.actions/) + assert.doesNotMatch(source, /CUSTOMS_DECLARATION|loading-customs|customsPath/) + assert.match(source, /tasks\/\$\{taskId\}\/evidence/) assert.doesNotMatch(source, /internal\/file|business-files/) assert.doesNotMatch(source, /\{\s*fileId\s*:/) }) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 6851682de..6390897a6 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -49,31 +49,31 @@ async function uploadDocument(context, path, expectedVersion, operationReason, l } async function ensureCompletionEvidence(context, chain, label) { - const tenantId = chain.source.tenantId - const inboundId = chain.source.inboundId + const taskId = id(chain.task?.id) + const warehouseId = id(chain.source?.warehouseId) const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { - const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` + const query = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType, + remark: context.marker }) + if (evidenceType === 'SEAL') query.set('sealNo', `SEAL-${suffix(context, label)}`.slice(0, 64)) + const uploadPath = `${PDA}/tasks/${taskId}/evidence?${query}` const form = new FormData() form.append('file', new Blob([png], { type: 'image/png' }), `${label}-${evidenceType}.png`) const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { - method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + 'Idempotency-Key': operationId(context, `${label}-evidence-${evidenceType}`) }, body: form, signal: AbortSignal.timeout(60_000), }) const uploadBody = await rawUpload.json() const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } - const file = context.apiSuccess(MODULE, `${label} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) - const fileId = id(file?.id || file?.fileId) - if (!fileId) throw new Error(`${label} ${evidenceType} 未返回文件ID`) - const bind = await post(context, - `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: 0, evidenceType, fileId, - sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix(context, label)}`.slice(0, 64) : null, - remark: context.marker, - }, `${label}-evidence-${evidenceType}`) - context.apiSuccess(MODULE, `${label} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS) + const evidence = context.apiSuccess(MODULE, `${label} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS) + const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType + && item.status !== 'VOID') + if (!active?.evidenceId || id(evidence?.taskId) !== taskId) { + throw new Error(`${label} ${evidenceType} 未返回任务证据ID`) + } } } @@ -133,11 +133,14 @@ async function createConfirmedContainer(context, source, label) { const agentPage = context.apiSuccess(MODULE, `${label} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS) const agent = agentPage?.records?.find?.((item) => item?.id && item?.name) if (!agent) throw new Error(`${label} 未取得 ACTIVE 订舱代理`) + const { ensureMatsonCarrierPartner } = await import('./carrier-partner-fixture.mjs') + const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) + if (!carrier?.id) throw new Error(`${label} 未取得 ACTIVE 船公司`) const draft = { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', channelCode: summary.channelCode, warehouseId: source.warehouseId, billOfLadingNo: `BL-${value}`.slice(0, 128), - shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, carrierCode: 'OTHER', + shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: carrier.carrierCode, bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', @@ -161,8 +164,20 @@ async function createConfirmedContainer(context, source, label) { { expectedVersion: 0, data: draft }, `${label}-booking-create`) const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS) const bookingId = id(created?.oceanBooking?.id) + if (!bookingId) throw new Error(`${label} 创建订舱未返回订舱ID`) + const documentForm = new FormData() + documentForm.append('expectedVersion', String(Number(created?.oceanBooking?.version))) + documentForm.append('documentType', 'BOOKING_CONFIRMATION') + documentForm.append('file', new Blob([`${context.marker}-${label}-BOOKING_CONFIRMATION`], + { type: 'text/plain' }), `booking-confirmation-${value}.txt`) + const documentResponse = await context.requestMultipart('POST', + `/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, documentForm, context.token, + { 'Idempotency-Key': operationId(context, `${label}-booking-document`) }) + const document = context.apiSuccess(MODULE, `${label} 上传订舱确认文件`, documentResponse, '', OPTIONS) + const releaseVersion = Number(document?.bookingVersion) + if (!Number.isInteger(releaseVersion)) throw new Error(`${label} 订舱确认文件未返回有效版本`) const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, { - expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker, + expectedVersion: releaseVersion, reason: context.marker, }, `${label}-booking-release`) const confirmed = context.apiSuccess(MODULE, `${label} 放舱订舱`, releasedResponse, '', OPTIONS) context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse, @@ -179,12 +194,13 @@ async function createConfirmedContainer(context, source, label) { throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`) } context.apiSuccess(MODULE, `${label} 放舱后关联真实柜号`, linkedResponse, '', OPTIONS) - return containerId + return { containerId, containerNo } } async function createPlanTask(context, label) { const source = await readInbound(context, await prepareInbound(context, label)) - const containerId = await createConfirmedContainer(context, source, label) + const container = await createConfirmedContainer(context, source, label) + const { containerId, containerNo } = container const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`) const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS) @@ -288,6 +304,15 @@ async function complete(context, chain, label) { remark: `${context.marker} 人工完成状态样本`, }) chain.task = context.apiSuccess(MODULE, `${label} 人工确认装柜完成`, response, '', OPTIONS) + for (let attempt = 0; attempt < 90 && chain.task?.taskStatus !== 'COMPLETED' + && chain.task?.taskStatus !== 'COMPLETION_FAILED'; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1_000)) + const detailResponse = await context.request('GET', + `${PDA}/tasks/${chain.task.id}?warehouseId=${encodeURIComponent(chain.source.warehouseId)}`) + if (detailResponse.http === 200 && String(detailResponse?.body?.code) === '200') { + chain.task = detailResponse.body?.data + } + } return chain } @@ -351,39 +376,11 @@ async function completeSimpleDocuments(context, chain, matrix) { && 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) { @@ -396,13 +393,13 @@ export async function run(context, matrix = context.createdResources.manualState requiredActions: ['clearance'], fixtures: {}, actions: {} } matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} } matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], - requiredActions: ['customsDeclaration'], fixtures: {}, actions: {} } + requiredActions: [], 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 waitingContainer = await createConfirmedContainer(context, waitingSource, 'WAIT') + matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainer.containerId, 'WAIT_LOADING', + `/container-loading?containerId=${waitingContainer.containerId}`) const editing = await createPlanTask(context, 'EDIT') const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS') diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 3e0784a70..8ba9e6af8 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -39,9 +39,11 @@ async function raw(context, method, path, body, token, headers = {}) { async function loginMerchant(context) { const account = context.context?.merchantQualificationFixture - if (!account?.username || !account?.password) throw new Error('缺少本轮客户登录凭据') + const username = account?.username || process.env.MERCHANT_TEST_USERNAME || '' + const password = account?.password || process.env.MERCHANT_TEST_PASSWORD || '' + if (!username || !password) throw new Error('缺少客户登录凭据') const response = await context.request('POST', '/api/auth/login', { - username: account.username, password: account.password, + username, password, }, '') const token = success(response) ? response.body?.data?.accessToken : null if (!token) throw new Error(`本轮客户登录失败: requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index be09b1e0e..6a4eff852 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -69,31 +69,28 @@ async function request(context, method, path, body, headers = {}, token = contex } } -async function ensureCompletionEvidence(context, tenantId, inboundId, keyBase) { +async function ensureCompletionEvidence(context, taskId, warehouseId, keyBase) { const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { - const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` + const query = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType, + remark: context.marker }) + if (evidenceType === 'SEAL') query.set('sealNo', `SEAL-${keyBase}`.slice(0, 64)) + const uploadPath = `${PDA}/tasks/${taskId}/evidence?${query}` const form = new FormData() form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`) const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { - method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + 'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) }, body: form, signal: AbortSignal.timeout(60_000), }) const uploadBody = await rawUpload.json() const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } - const file = context.apiSuccess(MODULE, `${keyBase} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) - const fileId = id(file?.id || file?.fileId) - if (!fileId) return false - const bind = await request(context, 'POST', - `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: 0, evidenceType, fileId, - sealNo: evidenceType === 'SEAL' ? `SEAL-${keyBase}`.slice(0, 64) : null, - remark: context.marker, - }, { 'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) }) - const bound = context.apiSuccess(MODULE, `${keyBase} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS) - if (!bound) return false + const evidence = context.apiSuccess(MODULE, `${keyBase} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS) + const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType + && item.status !== 'VOID') + if (!active?.evidenceId || id(evidence?.taskId) !== taskId) return false } return true } @@ -201,8 +198,20 @@ async function loadOneFragment(context, state) { const oceanBookingId = id(bookingCreated?.oceanBooking?.id) if (!oceanBookingId) return null context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId) + const bookingDocumentForm = new FormData() + bookingDocumentForm.append('expectedVersion', String(Number(bookingCreated?.oceanBooking?.version))) + bookingDocumentForm.append('documentType', 'BOOKING_CONFIRMATION') + bookingDocumentForm.append('file', new Blob([`${context.marker}-${sourceOrderNo}-BOOKING_CONFIRMATION`], + { type: 'text/plain' }), `booking-confirmation-${operationBase.replace(/[^A-Za-z0-9-]/g, '')}.txt`) + const bookingDocumentResponse = await context.requestMultipart('POST', + `/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`, bookingDocumentForm, context.token, + { 'Idempotency-Key': `${operationBase}-booking-document`.slice(0, 128) }) + const bookingDocument = context.apiSuccess(MODULE, `上传 ${sourceOrderNo} 的订舱确认文件`, + bookingDocumentResponse, '', OPTIONS) + const releaseVersion = Number(bookingDocument?.bookingVersion) + if (!Number.isInteger(releaseVersion)) return null const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, { - expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker, + expectedVersion: releaseVersion, reason: context.marker, }, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) }) const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS) context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse, @@ -419,7 +428,7 @@ async function loadOneFragment(context, state) { `HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS) } - if (!await ensureCompletionEvidence(context, tenantId, id(candidate.inboundId), operationBase)) return null + if (!await ensureCompletionEvidence(context, taskId, warehouseId, operationBase)) return null const completePath = `${PDA}/tasks/${taskId}/complete` const completePayload = { @@ -427,8 +436,18 @@ async function loadOneFragment(context, state) { remark: context.marker, } const complete = await request(context, 'POST', completePath, completePayload) - const completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS) - context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, complete, + let completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS) + let completedResponse = complete + for (let attempt = 0; attempt < 90 && completed?.taskStatus !== 'COMPLETED' + && completed?.taskStatus !== 'COMPLETION_FAILED'; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1_000)) + completedResponse = await context.request('GET', + `${PDA}/tasks/${taskId}?warehouseId=${encodeURIComponent(warehouseId)}`) + if (completedResponse.http === 200 && codeOf(completedResponse) === '200') { + completed = completedResponse.body?.data + } + } + context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, completedResponse, completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' && completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id) && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),