diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 64f19c923..10332299f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -40,6 +40,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; @@ -785,6 +787,7 @@ class LoadingPickServiceTest { @Test void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() { + assertNoTruckCoupling(); LoadingPickTaskEntity failed = task(); failed.setTaskStatus("COMPLETION_FAILED"); LoadingPickTaskEntity completing = task(); @@ -871,6 +874,35 @@ class LoadingPickServiceTest { verify(completionCommands).complete(900L, "worker-1"); } + @ParameterizedTest(name = "truck state {0} never participates in PDA completion") + @ValueSource(strings = {"NO_APPOINTMENT", "DRAFT", "IN_PROGRESS", "COMPLETED"}) + void completionWorkerIsIndependentOfEveryTruckAppointmentState(String truckState) { + assertNoTruckCoupling(); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.review(20L)).thenReturn(null); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + long commandId = 920L + Math.abs(truckState.hashCode() % 20); + when(completionCommands.complete(commandId, "worker-truck-independent")).thenReturn(true); + + service.processCompletion(completionCommand(commandId, + "async-truck-independent-" + truckState.toLowerCase()), "worker-truck-independent"); + + verify(cabinetEvidenceService).requireComplete(99L); + verify(settlementService).settleOwner(20L, 50L, 9L, "operator"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-async-truck-independent-" + truckState.toLowerCase() + "-50", "checked"); + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService).publishCompletedEvent(10L); + verify(repository).completeAccepted(20L, 9L, "operator"); + verify(completionCommands).complete(commandId, "worker-truck-independent"); + } + @Test void workerUsesCabinetEvidenceOnceWithReversedTwoCustomerOwnerOrder() { LoadingPickTaskEntity completing = task(); @@ -911,6 +943,7 @@ class LoadingPickServiceTest { @Test void secondCustomerOwnerReadyFailureLeavesEveryOwnerUnsettledAndUncompleted() { + assertNoTruckCoupling(); LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); @@ -939,6 +972,7 @@ class LoadingPickServiceTest { @Test void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() { + assertNoTruckCoupling(); LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); @@ -1180,6 +1214,14 @@ class LoadingPickServiceTest { verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); } + private static void assertNoTruckCoupling() { + assertThat(LoadingPickService.class.getDeclaredFields()) + .noneMatch(field -> field.getType().equals(TruckAppointmentService.class)); + assertThat(LoadingPickService.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("completeTruckAfterCommit") + || method.getName().equals("completeLoadingFromPda")); + } + private void preparePick(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) { when(repository.history(operationId)).thenReturn(null); when(repository.task(20L)).thenReturn(task); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 124f90696..94356e56c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -455,6 +455,78 @@ class TruckAppointmentServiceTest { assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED"); } + @Test void createRejectsUnknownBookingWithoutWriting() { + SaveRequest request = validRequest(); + when(oceanBookings.find(99L)).thenReturn(null); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("关联订舱不存在"); + + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); + } + + @Test void createRejectsWarehouseMismatchWithoutWriting() { + SaveRequest request = validRequest(); + when(request.warehouseId()).thenReturn(19L); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("warehouseId与订舱不一致"); + + verify(repository, never()).insert(any()); + } + + @Test void createRejectsInvalidTruckingSupplierWithoutWriting() { + SaveRequest request = validRequest(); + when(suppliers.trucking(8L, null)).thenThrow( + new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "拖车服务商未启用")); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("拖车服务商未启用"); + + verify(repository, never()).insert(any()); + } + + @Test void editRejectsTargetBookingOwnedByAnotherActiveAppointment() { + TruckAppointmentEntity current = entity(TruckAppointmentStatus.DRAFT, 3); + current.setOceanBookingId(99L); + TruckAppointmentEntity occupied = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + occupied.setId(2L); occupied.setOceanBookingId(199L); occupied.setAppointmentNo("TA-260901-0099"); + SaveRequest request = validRequest(); + when(request.expectedVersion()).thenReturn(3); + when(request.oceanBookingId()).thenReturn(199L); + OceanBookingEntity target = releasedBooking(); target.setId(199L); + when(oceanBookings.find(199L)).thenReturn(target); + when(repository.find(1L)).thenReturn(current); + when(repository.firstActiveForUpdate(199L)).thenReturn(occupied); + + assertThatThrownBy(() -> service.edit(1L, 7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("TA-260901-0099"); + + verify(repository, never()).updateEdit(any(), anyInt()); + } + + @Test void detailJsonContainsNoContainerProtocolFields() throws Exception { + TruckAppointmentEntity appointment = entity(TruckAppointmentStatus.DRAFT, 0); + appointment.setOceanBookingId(99L); appointment.setContainerBookingId(88L); + appointment.setLoadingPlanId(77L); appointment.setCabinetNo("CAB-OLD"); appointment.setContainerNo("MSCU6639870"); + when(repository.find(1L)).thenReturn(appointment); + + String json = objectMapper.writeValueAsString(service.detail(1L)); + + assertThat(json).doesNotContain("containerBookingId", "loadingPlanId", "cabinetNo", "containerNo", "containers"); + assertThat(json).contains("oceanBookingId"); + } + + @Test void deleteAttachmentKeepsAppointmentOwnershipGuard() { + when(repository.find(1L)).thenReturn(entity(TruckAppointmentStatus.DRAFT, 0)); + when(repository.deleteAttachment(1L, 77L)).thenReturn(1); + + service.deleteAttachment(1L, 77L); + + verify(repository).deleteAttachment(1L, 77L); + } + private String requestHash(String action, long id, Object request) throws Exception { String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request); return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 57c81e7fb..1770ed349 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -73,7 +73,7 @@ export async function run(context) { | PDA 人工领取、逐箱扫码、原子批量提交与待复核核验 | `manual-pda-batch-fixture.mjs`、`manual-pda-batch-verify.mjs`(可选,不进入默认全量) | `manual-pda-batch-contract.test.mjs` | | 五类装柜证据、PDA 最终完成与 LOADED 终态核验 | `manual-loading-complete-fixture.mjs`、`manual-loading-complete-verify.mjs`(可选,不进入默认全量) | `manual-loading-complete-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | -| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` | +| 订舱级拖车预约完整生命周期 | `truck-appointments.mjs`(自包含创建并放舱,不创建或关联柜次;每订舱最多一张非终态拖车单;覆盖候选、附件、费用、幂等及 `SUBMIT → DISPATCH → PICKUP → ARRIVE → LOAD → DEPART → COMPLETE`) | `truck-appointments-contract.test.mjs` | | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | 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 e71b8e220..10db522e8 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 @@ -120,7 +120,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) -test('downstream finalizer uses the frozen truck-coupled complete contract', () => { +test('downstream finalizer uses the frozen loading complete contract without truck coupling', () => { assert.match(convergence, /loadingPlan\?\.status === 'LOADING'/) assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/) assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/) @@ -136,6 +136,8 @@ test('downstream finalizer uses the frozen truck-coupled complete contract', () assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/) assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/) + assert.doesNotMatch(loadingPickService, + /TruckAppointmentService|completeLoadingFromPda|completeTruckAfterCommit/) }) test('PDA complete OpenAPI freezes reliable async acceptance and checkpoint recovery', () => { @@ -215,11 +217,12 @@ test('worker final Firstleg convergence is one short transaction after remote ow 'settlementService.settleOwner(', 'loadingDeliveryService.completeAfterPdaSettlement(', 'completionTransaction.executeWithoutResult', - 'completeTruckAfterCommit(', ]) const finalizer = loadingPickService.slice(loadingPickService.indexOf('private void finalizeCompletion'), loadingPickService.indexOf('private void requireColoadConservation')) for (const token of ['COMPLETE_LOADING', 'finishFromPda', 'syncInboundProjections', 'publishCompletedEvent', 'completeAccepted', 'completionCommands.complete']) assert.match(finalizer, new RegExp(token)) - assert.doesNotMatch(finalizer, /settlementService|completeTruckAfterCommit/) + assert.doesNotMatch(finalizer, /settlementService/) + assert.doesNotMatch(loadingPickService, + /TruckAppointmentService|completeLoadingFromPda|completeTruckAfterCommit/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 2d4bec66f..f7e3e7bf0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -917,6 +917,9 @@ export async function run(context) { acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200' && ['PROCESSING', 'FAILED', 'COMPLETED'].includes(acceptedComplete.body?.data?.workflowStatus), `${responseEvidence(acceptedComplete)}, workflow=${acceptedComplete.body?.data?.workflowStatus || 'missing'}`, OPTIONS) + context.assert(MODULE, '独立柜未创建拖车单也不阻塞 PDA 装柜完成命令', acceptedComplete, + acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200', + `containerBookingId=${containerBookingId}, oceanBookingId=${bookingId}, ${responseEvidence(acceptedComplete)}`, OPTIONS) let terminalTask = acceptedComplete.body?.data let terminalResponse = acceptedComplete diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index de5bff40e..d5b1bbbda 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -140,6 +140,22 @@ test('late container gate mode proves the final gate without requiring evidence assert.match(value, /nextStage === 'CABINET_EVIDENCE' && nextCode === 'A0409'/) assert.match(value, /!nextMessage\.includes\('集装箱号'\)/) assert.match(value, /context\.createdResources\.phase3LateContainerGate/) + assert.match(value, /独立柜未创建拖车单也不阻塞 PDA 装柜完成命令/) +}) + +test('PDA completion production and HTTP chain never call or gate on truck appointments', () => { + const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java') + const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java') + const loading = source('./phase3-loading.mjs') + assert.doesNotMatch(service, /TruckAppointmentService|completeLoadingFromPda|completeTruckAfterCommit/) + assert.match(serviceTest, /completionWorkerIsIndependentOfEveryTruckAppointmentState/) + for (const state of ['NO_APPOINTMENT', 'DRAFT', 'IN_PROGRESS', 'COMPLETED']) { + assert.match(serviceTest, new RegExp(state)) + } + assert.match(serviceTest, /failedSameOperationIdCanResumeWithoutCreatingAnotherCommand[\s\S]*assertNoTruckCoupling/) + assert.match(serviceTest, /secondCustomerOwnerReadyFailureLeavesEveryOwnerUnsettledAndUncompleted[\s\S]*assertNoTruckCoupling/) + assert.match(serviceTest, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements[\s\S]*assertNoTruckCoupling/) + assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/) }) test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => { diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 7c31f133c..9e90ca778 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -2,136 +2,97 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { activeCustomerIdentity, inspectOceanBookingIdentity, minimalPdfBytes, platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs' +import { activeCustomerIdentity, inspectOceanBookingIdentity, minimalPdfBytes, + platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs' -test('PDF upload fixtures contain real PDF magic bytes instead of MIME-only text', () => { - const bytes = minimalPdfBytes('RUN-2-DISPATCH_ORDER') - const content = Buffer.from(bytes).toString('ascii') +const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + +test('PDF fixtures contain actual PDF magic bytes', () => { + const content = Buffer.from(minimalPdfBytes('TRUCK')).toString('ascii') assert.equal(content.slice(0, 5), '%PDF-') assert.match(content, /%%EOF\n$/) - - const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - assert.match(source, /new Blob\(\[minimalPdfBytes\(`\$\{context\.marker\}-BILL_OF_LADING`\)\], \{ type: 'application\/pdf' \}\)/) - assert.match(source, /new Blob\(\[minimalPdfBytes\(`\$\{context\.marker\}-DISPATCH_ORDER`\)\], \{ type: 'application\/pdf' \}\)/) - assert.doesNotMatch(source, /new Blob\(\[\s*`\$\{context\.marker\}-(?:BILL_OF_LADING|DISPATCH_ORDER)`\s*\], \{ type: 'application\/pdf' \}\)/) }) -test('booking prerequisite resolves an ACTIVE platform customer without fixture state', () => { +test('platform customer and warehouse helpers remain bounded', () => { assert.deepEqual(activeCustomerIdentity({ id: '21', tenantName: '现有客户' }), { customerId: '21', customerName: '现有客户' }) - assert.deepEqual(activeCustomerIdentity({ tenantId: 22, customerName: '备用客户' }), - { customerId: '22', customerName: '备用客户' }) assert.equal(activeCustomerIdentity({ id: '23' }), null) - const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - assert.match(source, /\/api\/platform\/v1\/iam\/tenants\?page=1&size=100&status=ACTIVE/) - assert.match(source, /fixtureCustomerId[\s\S]*\|\| candidates\[0\]/) + assert.deepEqual(platformWarehouseIdentity({ warehouseId: '41' }), { warehouseId: '41', platformTenantId: '0' }) }) -test('channel catalog accepts the formal array response shape', () => { - const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - assert.match(source, /const channelRows = records\(data\(channelsResponse\)\); const channel = channelRows\[0\]/) - assert.doesNotMatch(source, /\(data\(channelsResponse\) \|\| \[\]\)\[0\]/) -}) - -test('ACTIVE domestic platform warehouses are scanned through bounded pagination', () => { - const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - assert.match(source, /for \(let page = 1; page <= 100; page\+\+\)/) - assert.match(source, /warehouse-admin\?tenantId=0&warehouseType=DOMESTIC&status=ACTIVE&page=\$\{page\}&size=100/) - assert.match(source, /const total = Number\(warehousePage\?\.total \?\? warehouseRows\.length\)/) - assert.match(source, /page \* 100 >= total/) - assert.match(source, /完整分页平台仓并解析有效海运渠道/) - assert.doesNotMatch(source, /193000000000200001|193000000000200002/) -}) - -test('platform warehouse scope is fixed to zero without requiring response tenantId', () => { - assert.deepEqual(platformWarehouseIdentity({ warehouseId: '41', warehouseName: '宁波仓' }), - { warehouseId: '41', platformTenantId: '0' }) - assert.deepEqual(platformWarehouseIdentity({ id: 42, warehouseName: '义乌仓' }), - { warehouseId: '42', platformTenantId: '0' }) - const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - assert.match(source, /platformWarehouseIdentity\(item\)[\s\S]*platformTenantId: '0'/) - assert.doesNotMatch(source, /platformWarehouseIdentity\(item\)[^{]*\{[^}]*tenantId/) -}) - -test('booking identity is marker scoped and contains no customer snapshot', () => { - const result = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2', warehouseId: '41' }) +test('booking identity contains no customer or container snapshot', () => { + const result = inspectOceanBookingIdentity('RUN', { id: '91', marker: 'RUN', warehouseId: '41' }) assert.deepEqual(result.identity, { oceanBookingId: '91', warehouseId: '41' }) - assert.equal(Object.hasOwn(result.identity, 'customerId'), false) - assert.equal(inspectOceanBookingIdentity('RUN-2', { id: '90', marker: 'RUN-1', warehouseId: '41' }).identity, null) + for (const field of ['customerId', 'containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo']) { + assert.equal(Object.hasOwn(result.identity, field), false) + } }) -test('payload requires booking container and carrier but never emits customer fields', () => { +test('save payload is strictly booking level', () => { const context = { createdResources: { truckSupplier: { supplierId: '501', supplierName: '正式拖车商' } } } const payload = truckAppointmentPayload(context, '2', { - oceanBookingId: '91', containerBookingId: '81', loadingPlanId: '71', warehouseId: '41', billOfLadingNo: 'BL-1', + oceanBookingId: '91', warehouseId: '41', billOfLadingNo: 'BL-1', }) - assert.deepEqual({ oceanBookingId: payload.oceanBookingId, containerBookingId: payload.containerBookingId, - carrierId: payload.carrierId, carrierName: payload.carrierName }, - { oceanBookingId: '91', containerBookingId: '81', carrierId: '501', carrierName: '正式拖车商' }) - assert.equal(Object.hasOwn(payload, 'customerId'), false) - assert.equal(Object.hasOwn(payload, 'customerName'), false) - assert.equal(Object.hasOwn(payload, 'receivableAmount'), false) + assert.equal(payload.oceanBookingId, '91') + assert.equal(payload.carrierId, '501') + for (const field of ['customerId', 'customerName', 'containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers']) { + assert.equal(Object.hasOwn(payload, field), false, field) + } }) -test('backend DTO entity mapper service and OpenAPI have no truck customer protocol', () => { +test('fixture creates and releases booking without creating or linking a container', () => { + for (const label of ['自包含创建 DRAFT 订舱', 'DRAFT订舱不进入拖车候选', + '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱', 'RELEASED且资料有效的订舱进入候选']) { + assert.match(source, new RegExp(label)) + } + assert.doesNotMatch(source, /\/api\/platform\/v1\/containers|ocean-booking-link|isoContainerNo|创建自包含模拟柜/) +}) + +test('HTTP lifecycle covers uniqueness idempotency attachments and every action', () => { + for (const label of ['创建后订舱立即退出候选', '同订舱第二张非终态拖车单冲突', + '重复创建未改变版本和状态', '上传DISPATCH_ORDER拖车附件', '查询拖车附件列表', + '编辑订舱级拖车为USD应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放', + 'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409', + 'COMPLETED后订舱恢复候选', 'COMPLETED后同订舱可新建下一张拖车单', + 'CANCELLED后订舱恢复候选', 'CANCELLED后同订舱可再次新建', + 'EXCEPTION后订舱恢复候选', 'EXCEPTION后同订舱可再次新建']) { + assert.match(source, new RegExp(label), label) + } + assert.match(source, /\['DISPATCH', '派车'\], \['PICKUP', '提柜'\], \['ARRIVE', '到仓'\]/) + assert.match(source, /\['LOAD', '装货'\], \['DEPART', '离仓'\], \['COMPLETE', '完成'\]/) + assert.match(source, /context\.apiSuccess\(MODULE, `整单推进\$\{name\}`/) +}) + +test('negative matrix covers booking warehouse bill carrier and currency', () => { + for (const label of ['错误订舱拒绝创建', '错误仓库拒绝创建', '错误提单拒绝创建', + '错误承运商拒绝创建', '非法拖车币种拒绝创建']) assert.match(source, new RegExp(label)) +}) + +test('backend and OpenAPI expose no container write or response protocol', () => { const paths = [ '../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java', - '../../firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', '../../firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java', '../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', '../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', ] - for (const path of paths) { - const source = readFileSync(new URL(path, import.meta.url), 'utf8') - assert.doesNotMatch(source, /customerId|customerName|customer_id|customer_name/) - } -}) - -test('backend freezes released booking real container bill file and carrier rules', () => { - const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java', import.meta.url), 'utf8') + const dto = readFileSync(new URL(paths[0], import.meta.url), 'utf8') assert.match(dto, /@NotNull @Positive Long oceanBookingId/) - assert.match(dto, /@NotNull @Positive Long containerBookingId/) - assert.match(dto, /@NotNull @Positive Long carrierId/) - const service = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', import.meta.url), 'utf8') - for (const token of ['"RELEASED"', 'containerBookingId未分配至该订舱', 'Iso6346ContainerNumber.require', - 'oceanBookingDocuments.requireActiveValidDocument', 'suppliers.trucking', 'uk_truck_active_container']) { - assert.ok(service.includes(token), `missing ${token}`) + assert.doesNotMatch(dto, /SaveRequest\([^)]*(containerBookingId|loadingPlanId|containerNo|cabinetNo)/s) + const openApi = readFileSync(new URL(paths[3], import.meta.url), 'utf8') + for (const field of ['containerBookingId:', 'loadingPlanId:', 'containerNo:', 'cabinetNo:', 'containers:']) { + assert.doesNotMatch(openApi, new RegExp(field)) } }) -test('HTTP regression is self-contained and covers no-customer and idempotency matrix', () => { - const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱', - '绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移', - '同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建', - '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', '非法拖车币种拒绝创建', - 'RMB应付费用响应无客户及应收字段', '编辑为USD应付费用', 'USD应付费用保存且无应收字段', - '上传并关联DISPATCH_ORDER拖车附件', '拖车附件类型仅使用运营附件而非订舱提单', - '删除首次上传的拖车附件引用', '重新引用平台归属拖车附件', '附件列表包含重新引用的DISPATCH_ORDER', - '删除重新引用的拖车附件', - '派车命令返回状态身份与版本正确', '派车后回查拖车完整详情', - '详情字段完整且订舱柜号身份无客户字段漂移']) assert.match(source, new RegExp(label)) - assert.match(source, /const detailResponse = await request\(context, 'GET', path\); const detail = data\(detailResponse\)/) - assert.match(source, /const detailGaps = truckAppointmentFieldGaps\(detail\)/) - assert.doesNotMatch(source, /truckAppointmentFieldGaps\(dispatched\)/) - assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/) - assert.doesNotMatch(source, /receivableAmount\s*:/) +test('schema owns one active appointment per ocean booking', () => { + const sql = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') + assert.match(sql, /UNIQUE KEY uk_truck_active_ocean_booking\(active_ocean_booking_id\)/) + assert.match(sql, /DROP INDEX uk_truck_active_container/) + assert.match(sql, /visibility VARCHAR\(24\) NOT NULL DEFAULT 'PLATFORM'/) +}) + +test('admin entry keeps truck module independent of loading fixtures', () => { const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/) assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/) }) - -test('schema owns platform attachments and one active appointment per real container', () => { - const sql = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') - assert.doesNotMatch(sql, /customer_id|customer_name|idx_truck_appointment_customer/) - assert.match(sql, /UNIQUE KEY uk_truck_active_container\(active_container_booking_id\)/) - assert.match(sql, /visibility VARCHAR\(24\) NOT NULL DEFAULT 'PLATFORM'/) -}) - -test('method permissions remain independently enforced after removing customer scope', () => { - const controller = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java', import.meta.url), 'utf8') - for (const permission of ['platform:firstleg:truck-appointment:list', 'platform:firstleg:truck-appointment:create', - 'platform:firstleg:truck-appointment:edit', 'platform:firstleg:truck-appointment:operate']) { - assert.match(controller, new RegExp(`hasAuthority\\('${permission}'\\)`)) - } -}) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 502eed3a5..d87df2a34 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -1,18 +1,18 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs' -import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs' const MODULE = 'truck-appointments' -const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true } +const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true } const TRUCKS = '/api/platform/v1/truck-appointments' const BOOKINGS = '/api/platform/v1/ocean-bookings' -const CONTAINERS = '/api/platform/v1/containers' +const FORBIDDEN_TRUCK_FIELDS = ['containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers'] function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function data(response) { return codeOf(response) === '200' ? response.body.data : null } function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } function records(value) { return Array.isArray(value) ? value : value?.records || [] } +function hasForbiddenTruckFields(value) { return FORBIDDEN_TRUCK_FIELDS.some((field) => Object.hasOwn(value || {}, field)) } export function minimalPdfBytes(label = 'truck-appointment-fixture') { const safeLabel = String(label).replace(/[^\x20-\x7E]/g, '_').slice(0, 64) @@ -63,8 +63,7 @@ export function inspectOceanBookingIdentity(marker, resource, detail = null) { export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) { const supplier = identity.truckSupplier || context.createdResources?.truckSupplier || {} - return { outboundOrderId: null, oceanBookingId: identity.oceanBookingId, - containerBookingId: identity.containerBookingId, loadingPlanId: null, warehouseId: identity.warehouseId, + return { outboundOrderId: null, oceanBookingId: identity.oceanBookingId, warehouseId: identity.warehouseId, carrierId: id(supplier.supplierId), carrierName: supplier.supplierName, billOfLadingNo: identity.billOfLadingNo, fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005', vehicleNo: `粤B-${suffix.slice(-6)}`, @@ -72,7 +71,7 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0, storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000', packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`, plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, - currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides } + currency: 'RMB', remark: `订舱级拖车-${suffix}`, expectedVersion: version, ...overrides } } async function activeSupplier(context, category, suffix) { @@ -111,11 +110,7 @@ async function bookingMaster(context, suffix) { if (!warehouseId) continue const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`) const channelRows = records(data(channelsResponse)); const channel = channelRows[0] - if (channel?.channelCode) { - context.addStep(MODULE, '完整分页平台仓并解析有效海运渠道', channelsResponse, true, - `warehousePage=${page}, warehouseRecords=${warehouseRows.length}, channelRecords=${channelRows.length}, warehouseId=${warehouseId}`, OPTIONS) - return { ...customer, warehouseId, channelCode: channel.channelCode, channelName: channel.channelName } - } + if (channel?.channelCode) return { ...customer, warehouseId, channelCode: channel.channelCode, channelName: channel.channelName } } const total = Number(warehousePage?.total ?? warehouseRows.length) if (warehouseRows.length === 0 || page * 100 >= total) break @@ -125,13 +120,10 @@ async function bookingMaster(context, suffix) { return null } -function isoContainerNo(marker) { - const values = {}; let value = 10 - for (let code = 65; code <= 90; code++) { while (value % 11 === 0) value++; values[String.fromCharCode(code)] = value++ } - const serial = String([...marker].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 0) % 1_000_000).padStart(6, '0') - const base = `TSTU${serial}`; let sum = 0 - ;[...base].forEach((char, index) => { sum += (values[char] ?? Number(char)) * (2 ** index) }) - return `${base}${(sum % 11) % 10}` +async function candidate(context, identity) { + const response = await request(context, 'GET', `${TRUCKS}/candidates?keyword=${encodeURIComponent(identity.oceanBookingNo)}&page=1&size=200`) + const page = data(response) + return { response, item: records(page).find((value) => id(value?.oceanBookingId) === identity.oceanBookingId) } } async function fixture(context) { @@ -150,6 +142,12 @@ async function fixture(context) { { 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) }) const created = context.apiSuccess(MODULE, '自包含创建 DRAFT 订舱', createdResponse, '', OPTIONS) const bookingId = id(created?.oceanBooking?.id); if (!bookingId) return null + const identity = { suffix, oceanBookingId: bookingId, oceanBookingNo: created.oceanBooking.bookingNo, + warehouseId: master.warehouseId, billOfLadingNo: created.oceanBooking.billOfLadingNo, truckSupplier } + const draftCandidate = await candidate(context, identity) + context.assert(MODULE, 'DRAFT订舱不进入拖车候选', draftCandidate.response, !draftCandidate.item, + `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) + const form = new FormData(); form.append('expectedVersion', String(created.oceanBooking.version)) form.append('documentType', 'BILL_OF_LADING') form.append('file', new Blob([minimalPdfBytes(`${context.marker}-BILL_OF_LADING`)], { type: 'application/pdf' }), `bill-${suffix}.pdf`) @@ -160,32 +158,36 @@ async function fixture(context) { { expectedVersion: Number(uploaded?.bookingVersion), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-truck-booking-release`.slice(0, 128) }) const released = context.apiSuccess(MODULE, '放舱自包含订舱', releaseResponse, '', OPTIONS) - const requirement = released?.containerRequirements?.[0]; if (released?.oceanBooking?.status !== 'RELEASED' || !requirement?.id) return null - const simulationResponse = await request(context, 'POST', CONTAINERS, { channelCode: master.channelCode, - warehouseId: master.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) - const simulation = context.apiSuccess(MODULE, '创建自包含模拟柜', simulationResponse, '', OPTIONS) - const containerNo = isoContainerNo(context.marker); if (!simulation?.id) return null - const linkResponse = await request(context, 'POST', `${CONTAINERS}/${simulation.id}/ocean-booking-link`, { - oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version), - }) - const linked = context.apiSuccess(MODULE, '绑定本轮真实 ISO 柜号', linkResponse, '', OPTIONS) - if (linked?.containerNo !== containerNo) return null - return { suffix, oceanBookingId: bookingId, containerBookingId: id(linked.id), warehouseId: master.warehouseId, - billOfLadingNo: released.oceanBooking.billOfLadingNo, truckSupplier, bookingVersion: released.oceanBooking.version } + if (released?.oceanBooking?.status !== 'RELEASED') return null + identity.billOfLadingNo = released.oceanBooking.billOfLadingNo + const releasedCandidate = await candidate(context, identity) + context.assert(MODULE, 'RELEASED且资料有效的订舱进入候选', releasedCandidate.response, + Boolean(releasedCandidate.item) && !hasForbiddenTruckFields(releasedCandidate.item), + `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) + return identity +} + +async function action(context, path, appointment, name, reason) { + const response = await request(context, 'POST', `${path}/${name.toLowerCase()}`, + { expectedVersion: Number(appointment?.version), reason }, + { 'Idempotency-Key': `${context.marker}-truck-${name.toLowerCase()}`.slice(0, 128) }) + return context.apiSuccess(MODULE, `整单推进${name}`, response, '', OPTIONS) } export async function run(context) { const identity = await fixture(context) - context.assert(MODULE, '拖车模块自包含前置链完整', null, Boolean(identity?.oceanBookingId && identity?.containerBookingId), - `oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}`, OPTIONS) + context.assert(MODULE, '订舱级拖车模块自包含前置链完整', null, + Boolean(identity?.oceanBookingId && identity?.oceanBookingNo && identity?.truckSupplier?.supplierId), + `oceanBookingNo=${identity?.oceanBookingNo || 'missing'}`, OPTIONS) if (!identity) return context.createdResources.truckSupplier = identity.truckSupplier const base = truckAppointmentPayload(context, identity.suffix, identity) - context.assert(MODULE, '拖车请求不含客户字段', null, - !Object.hasOwn(base, 'customerId') && !Object.hasOwn(base, 'customerName'), Object.keys(base).join(','), OPTIONS) + context.assert(MODULE, '创建请求完全不含柜次协议字段', null, + !hasForbiddenTruckFields(base) && !Object.hasOwn(base, 'customerId') && !Object.hasOwn(base, 'customerName'), + Object.keys(base).join(','), OPTIONS) for (const [label, payload] of [ ['错误订舱拒绝创建', { ...base, oceanBookingId: (BigInt(identity.oceanBookingId) + 1n).toString() }], - ['错误柜号归属拒绝创建', { ...base, containerBookingId: (BigInt(identity.containerBookingId) + 1n).toString() }], + ['错误仓库拒绝创建', { ...base, warehouseId: (BigInt(identity.warehouseId) + 1n).toString() }], ['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }], ['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }], ['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }], @@ -193,73 +195,89 @@ export async function run(context) { const response = await request(context, 'POST', TRUCKS, payload) context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS) } + const createdResponse = await request(context, 'POST', TRUCKS, base) - const appointment = context.apiSuccess(MODULE, '创建合法拖车预约', createdResponse, '', OPTIONS) + let appointment = context.apiSuccess(MODULE, '创建合法订舱级拖车预约', createdResponse, '', OPTIONS) const appointmentId = id(appointment?.id); if (!appointmentId) return - context.assert(MODULE, 'RMB应付费用响应无客户及应收字段', createdResponse, - appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0 - && !Object.hasOwn(appointment, 'receivableAmount') - && !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) const path = `${TRUCKS}/${appointmentId}` + context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse, + !hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0, + `appointmentNo=${appointment?.appointmentNo || 'missing'}`, OPTIONS) + const afterCreateCandidate = await candidate(context, identity) + context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item, + `appointmentNo=${appointment.appointmentNo}`, OPTIONS) + const duplicateBefore = await request(context, 'POST', TRUCKS, base) + context.addStep(MODULE, '同订舱第二张非终态拖车单冲突', duplicateBefore, failure(duplicateBefore), + `code=${codeOf(duplicateBefore)}, appointmentNo=${appointment.appointmentNo}`, OPTIONS) + const unchangedResponse = await request(context, 'GET', path) + const unchanged = context.apiSuccess(MODULE, '重复创建冲突后回查零漂移', unchangedResponse, '', OPTIONS) + context.assert(MODULE, '重复创建未改变版本和状态', unchangedResponse, + Number(unchanged?.version) === Number(appointment.version) && unchanged?.status === 'DRAFT', '', OPTIONS) + const attachmentForm = new FormData(); attachmentForm.append('attachmentType', 'DISPATCH_ORDER') attachmentForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER`)], { type: 'application/pdf' }), `dispatch-order-${identity.suffix}.pdf`) const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, attachmentForm, context.token) - const uploadedAttachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) - const uploadedAttachmentId = id(uploadedAttachment?.id); const uploadedFileId = id(uploadedAttachment?.fileId) - context.assert(MODULE, '拖车附件类型仅使用运营附件而非订舱提单', uploadAttachmentResponse, - Boolean(uploadedAttachmentId && uploadedFileId) && uploadedAttachment?.attachmentType === 'DISPATCH_ORDER', - `attachmentId=${uploadedAttachmentId || 'missing'}, fileId=${uploadedFileId || 'missing'}`, OPTIONS) - const deleteUploadedResponse = await request(context, 'DELETE', `${path}/attachments/${uploadedAttachmentId}`) - context.apiSuccess(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse, '', OPTIONS) - const referenceResponse = await request(context, 'POST', `${path}/attachments`, - { fileId: uploadedFileId, attachmentType: 'DISPATCH_ORDER' }) - const referencedAttachment = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referenceResponse, '', OPTIONS) - const referencedAttachmentId = id(referencedAttachment?.id) + const attachment = context.apiSuccess(MODULE, '上传DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) const attachmentListResponse = await request(context, 'GET', `${path}/attachments`) - const attachmentList = context.apiSuccess(MODULE, '查询拖车运营附件列表', attachmentListResponse, '', OPTIONS) - context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', attachmentListResponse, - Array.isArray(attachmentList) && attachmentList.some((item) => id(item?.id) === referencedAttachmentId - && item?.attachmentType === 'DISPATCH_ORDER'), `attachmentId=${referencedAttachmentId || 'missing'}`, OPTIONS) - const deleteReferencedResponse = await request(context, 'DELETE', `${path}/attachments/${referencedAttachmentId}`) - context.apiSuccess(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse, '', OPTIONS) - const usdResponse = await request(context, 'PUT', path, - { ...base, currency: 'USD', payableAmount: 12.34, expectedVersion: Number(appointment.version) }) - const usdAppointment = context.apiSuccess(MODULE, '编辑为USD应付费用', usdResponse, '', OPTIONS) - context.assert(MODULE, 'USD应付费用保存且无应收字段', usdResponse, - usdAppointment?.currency === 'USD' && Number(usdAppointment?.payableAmount) === 12.34 - && !Object.hasOwn(usdAppointment || {}, 'receivableAmount'), '', OPTIONS) - const duplicate = await request(context, 'POST', TRUCKS, base) - context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS) + const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS) + context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse, + records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'), + `attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS) + + const editedResponse = await request(context, 'PUT', path, + truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 })) + appointment = context.apiSuccess(MODULE, '编辑订舱级拖车为USD应付费用', editedResponse, '', OPTIONS) + context.assert(MODULE, '编辑响应费用正确且无柜次协议', editedResponse, + appointment?.currency === 'USD' && Number(appointment?.payableAmount) === 12.34 && !hasForbiddenTruckFields(appointment), '', OPTIONS) + const submitKey = `${context.marker}-truck-submit`.slice(0, 128) - const submitBody = { expectedVersion: Number(usdAppointment?.version), reason: '幂等提交' } - const first = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) - const firstData = context.apiSuccess(MODULE, '提交拖车预约', first, '', OPTIONS) - const replay = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) - const replayData = context.apiSuccess(MODULE, '同键同载荷重放', replay, '', OPTIONS) - context.assert(MODULE, '同键重放版本零漂移', replay, Number(firstData?.version) === Number(replayData?.version), '', OPTIONS) - const conflict = await request(context, 'POST', `${path}/submit`, { ...submitBody, reason: '异载荷' }, { 'Idempotency-Key': submitKey }) - context.addStep(MODULE, '同键异载荷拒绝', conflict, failure(conflict), `code=${codeOf(conflict)}`, OPTIONS) - const dispatch = await request(context, 'POST', `${path}/dispatch`, - { expectedVersion: Number(firstData?.version), reason: '派车' }, - { 'Idempotency-Key': `${context.marker}-truck-dispatch`.slice(0, 128) }) - const dispatched = context.apiSuccess(MODULE, '有效提单后派车', dispatch, '', OPTIONS) - context.assert(MODULE, '派车命令返回状态身份与版本正确', dispatch, - dispatched?.status === 'DISPATCHED' && id(dispatched?.id) === appointmentId - && Number(dispatched?.version) > Number(firstData?.version), - `id=${id(dispatched?.id) || 'missing'}, status=${dispatched?.status || 'missing'}, version=${dispatched?.version ?? 'missing'}`, OPTIONS) - const detailResponse = await request(context, 'GET', path); const detail = data(detailResponse) - context.apiSuccess(MODULE, '派车后回查拖车完整详情', detailResponse, '', OPTIONS) - const detailGaps = truckAppointmentFieldGaps(detail) - context.assert(MODULE, '详情字段完整且订舱柜号身份无客户字段漂移', detailResponse, - detail?.status === 'DISPATCHED' && detailGaps.length === 0 - && !Object.hasOwn(detail || {}, 'customerId') && !Object.hasOwn(detail || {}, 'customerName') - && !Object.hasOwn(detail || {}, 'receivableAmount') - && detail?.currency === 'USD' && Number(detail?.payableAmount) === 12.34 - && id(detail?.oceanBookingId) === identity.oceanBookingId - && id(detail?.containerBookingId) === identity.containerBookingId, - `missing=${detailGaps.join(',') || 'none'}, currency=${detail?.currency || 'missing'}, payableAmount=${detail?.payableAmount ?? 'missing'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS) - context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED', - oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId } + const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' } + const submitResponse = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) + appointment = context.apiSuccess(MODULE, '整单推进SUBMIT', submitResponse, '', OPTIONS) + const replayResponse = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) + const replay = context.apiSuccess(MODULE, 'SUBMIT同键同载荷重放', replayResponse, '', OPTIONS) + context.assert(MODULE, 'SUBMIT重放版本零漂移', replayResponse, + Number(replay?.version) === Number(appointment.version) && replay?.status === appointment.status, '', OPTIONS) + const conflictResponse = await request(context, 'POST', `${path}/submit`, { ...submitBody, reason: '异载荷' }, + { 'Idempotency-Key': submitKey }) + context.addStep(MODULE, 'SUBMIT同键异载荷A0409', conflictResponse, + codeOf(conflictResponse) === 'A0409', `code=${codeOf(conflictResponse)}`, OPTIONS) + + for (const [name, reason] of [['DISPATCH', '派车'], ['PICKUP', '提柜'], ['ARRIVE', '到仓'], + ['LOAD', '装货'], ['DEPART', '离仓'], ['COMPLETE', '完成']]) { + appointment = await action(context, path, appointment, name, reason) + context.assert(MODULE, `${name}响应无柜次协议`, null, Boolean(appointment) && !hasForbiddenTruckFields(appointment), + `status=${appointment?.status || 'missing'}`, OPTIONS) + } + const finalDetailResponse = await request(context, 'GET', path) + const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS) + context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse, + finalDetail?.status === 'COMPLETED' && !hasForbiddenTruckFields(finalDetail) + && id(finalDetail?.oceanBookingId) === identity.oceanBookingId, + `appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}`, OPTIONS) + const afterCompleteCandidate = await candidate(context, identity) + context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item), + `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) + const rebuiltResponse = await request(context, 'POST', TRUCKS, base) + let rebuilt = context.apiSuccess(MODULE, 'COMPLETED后同订舱可新建下一张拖车单', rebuiltResponse, '', OPTIONS) + context.assert(MODULE, '重建拖车单保持订舱级响应', rebuiltResponse, + rebuilt?.status === 'DRAFT' && !hasForbiddenTruckFields(rebuilt) + && id(rebuilt?.oceanBookingId) === identity.oceanBookingId, + `appointmentNo=${rebuilt?.appointmentNo || 'missing'}`, OPTIONS) + const cancelled = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'CANCEL', '取消测试单') + const afterCancelCandidate = await candidate(context, identity) + context.assert(MODULE, 'CANCELLED后订舱恢复候选', afterCancelCandidate.response, + cancelled?.status === 'CANCELLED' && Boolean(afterCancelCandidate.item), '', OPTIONS) + const afterCancelResponse = await request(context, 'POST', TRUCKS, base) + rebuilt = context.apiSuccess(MODULE, 'CANCELLED后同订舱可再次新建', afterCancelResponse, '', OPTIONS) + const excepted = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'EXCEPTION', '异常测试单') + const afterExceptionCandidate = await candidate(context, identity) + context.assert(MODULE, 'EXCEPTION后订舱恢复候选', afterExceptionCandidate.response, + excepted?.status === 'EXCEPTION' && Boolean(afterExceptionCandidate.item), '', OPTIONS) + const finalDraftResponse = await request(context, 'POST', TRUCKS, base) + const finalDraft = context.apiSuccess(MODULE, 'EXCEPTION后同订舱可再次新建', finalDraftResponse, '', OPTIONS) + context.createdResources.truckAppointment = { id: id(finalDraft?.id), appointmentNo: finalDraft?.appointmentNo, + marker: context.marker, finalStatus: finalDraft?.status, oceanBookingId: identity.oceanBookingId } }