diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 01225936e..bc6e01079 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -52,6 +52,25 @@ test('default fixture publishes a second marker-owned inbound for the same-plan }) test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => { + ordered(loading, [ + '创建三期 DRAFT 订舱(不生成真实柜)', + '三期 DRAFT 订舱返回稳定身份和初始版本', + '三期订舱上传当前有效订舱确认文件', + '订舱文件归属当前订舱且上传递增版本', + '订舱文件同键同载荷重放', + '订舱文件精确重放不新增文件或版本', + '回查订舱文件有效归属', + '同键重放后当前订舱仅保留一条目标有效文件', + '三期订舱 RELEASED 冻结运输快照', + ]) + assert.match(loading, /documentType', 'BOOKING_CONFIRMATION'/) + assert.match(loading, /id\(bookingDocument\?\.document\?\.oceanBookingId\) === bookingId/) + assert.match(loading, /releaseVersion > draftVersion/) + assert.match(loading, /'Idempotency-Key': bookingDocumentKey/) + assert.equal((loading.match(/'Idempotency-Key': bookingDocumentKey/g) || []).length, 2) + assert.match(loading, /Number\(bookingDocumentReplay\?\.bookingVersion\) === releaseVersion/) + assert.match(loading, /expectedVersion: releaseVersion, reason: context\.marker/) + assert.doesNotMatch(loading, /expectedVersion: draftVersion, reason: context\.marker/) ordered(loading, [ 'PDA 原子批次完成拣货并等待人工确认', '五类装柜证据完整且铅封号持久化', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 98e34c0a5..e90bd23eb 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -440,8 +440,52 @@ export async function run(context) { const bookingDraftResponse = context.apiSuccess(MODULE, '创建三期 DRAFT 订舱(不生成真实柜)', bookingCreateResponse, '', OPTIONS) const bookingId = id(bookingDraftResponse?.oceanBooking?.id) const draftVersion = Number(bookingDraftResponse?.oceanBooking?.version) + context.assert(MODULE, '三期 DRAFT 订舱返回稳定身份和初始版本', bookingCreateResponse, + Boolean(bookingId) && bookingDraftResponse?.oceanBooking?.status === 'DRAFT' + && Number.isInteger(draftVersion) && draftVersion >= 0, + `bookingId=${bookingId || 'missing'}, status=${bookingDraftResponse?.oceanBooking?.status || 'missing'}, version=${bookingDraftResponse?.oceanBooking?.version ?? 'missing'}`, OPTIONS) + if (!bookingId || !Number.isInteger(draftVersion) || draftVersion < 0) return + const bookingDocumentKey = `${context.marker}-p3-booking-document`.slice(0, 128) + const bookingDocumentForm = () => { + const form = new FormData() + form.append('expectedVersion', String(draftVersion)) + form.append('documentType', 'BOOKING_CONFIRMATION') + form.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }), + `booking-confirmation-${context.marker}.txt`) + return form + } + const bookingDocumentResponse = await context.requestMultipart('POST', + `/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, bookingDocumentForm(), context.token, + { 'Idempotency-Key': bookingDocumentKey }) + const bookingDocument = context.apiSuccess(MODULE, '三期订舱上传当前有效订舱确认文件', bookingDocumentResponse, '', OPTIONS) + const releaseVersion = Number(bookingDocument?.bookingVersion) + context.assert(MODULE, '订舱文件归属当前订舱且上传递增版本', bookingDocumentResponse, + Boolean(bookingDocument?.document?.id) && id(bookingDocument?.document?.oceanBookingId) === bookingId + && bookingDocument?.document?.documentType === 'BOOKING_CONFIRMATION' + && bookingDocument?.document?.valid === true && Number.isInteger(releaseVersion) + && releaseVersion > draftVersion, + `documentId=${id(bookingDocument?.document?.id) || 'missing'}, oceanBookingId=${id(bookingDocument?.document?.oceanBookingId) || 'missing'}, draftVersion=${draftVersion}, bookingVersion=${bookingDocument?.bookingVersion ?? 'missing'}`, OPTIONS) + if (!bookingId || !Number.isInteger(releaseVersion) || releaseVersion <= draftVersion) return + + const bookingDocumentReplayResponse = await context.requestMultipart('POST', + `/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, bookingDocumentForm(), context.token, + { 'Idempotency-Key': bookingDocumentKey }) + const bookingDocumentReplay = context.apiSuccess(MODULE, '订舱文件同键同载荷重放', bookingDocumentReplayResponse, '', OPTIONS) + context.assert(MODULE, '订舱文件精确重放不新增文件或版本', bookingDocumentReplayResponse, + id(bookingDocumentReplay?.document?.id) === id(bookingDocument?.document?.id) + && id(bookingDocumentReplay?.document?.oceanBookingId) === bookingId + && Number(bookingDocumentReplay?.bookingVersion) === releaseVersion, + `firstDocumentId=${id(bookingDocument?.document?.id) || 'missing'}, replayDocumentId=${id(bookingDocumentReplay?.document?.id) || 'missing'}, firstVersion=${releaseVersion}, replayVersion=${bookingDocumentReplay?.bookingVersion ?? 'missing'}`, OPTIONS) + + const bookingAfterDocumentResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${bookingId}`) + const bookingAfterDocument = context.apiSuccess(MODULE, '回查订舱文件有效归属', bookingAfterDocumentResponse, '', OPTIONS) + const activeBookingDocuments = (bookingAfterDocument?.documents || []).filter((document) => document?.valid === true) + context.assert(MODULE, '同键重放后当前订舱仅保留一条目标有效文件', bookingAfterDocumentResponse, + activeBookingDocuments.filter((document) => id(document?.id) === id(bookingDocument?.document?.id) + && id(document?.oceanBookingId) === bookingId && document?.documentType === 'BOOKING_CONFIRMATION').length === 1, + `activeDocuments=${activeBookingDocuments.length}, targetDocumentId=${id(bookingDocument?.document?.id) || 'missing'}`, OPTIONS) const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, - { expectedVersion: draftVersion, reason: context.marker }, + { expectedVersion: releaseVersion, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-booking-release`.slice(0, 128) }) const bookingConfirmed = context.apiSuccess(MODULE, '三期订舱 RELEASED 冻结运输快照', releaseResponse, '', OPTIONS) const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']