import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { inspectOceanBookingIdentity, selectIndependentTruckCustomer, truckAppointmentPayload } from './truck-appointments.mjs' test('truck appointment accepts only the current marker ocean booking identity', () => { const current = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2', customerId: '21', warehouseId: '41', customerName: '本轮客户', }) assert.deepEqual(current.identity, { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户', }) const stale = inspectOceanBookingIdentity('RUN-2', { id: '90', marker: 'RUN-1', customerId: '20', warehouseId: '40', customerName: '历史客户', }) assert.equal(stale.identity, null) assert.match(stale.reason, /marker 不属于本轮/) }) test('truck appointment fills missing identity only from the current booking detail', () => { const partial = { id: '91', marker: 'RUN-2', customerId: '21' } const beforeDetail = inspectOceanBookingIdentity('RUN-2', partial) assert.equal(beforeDetail.identity, null) assert.deepEqual(beforeDetail.missingFields, ['warehouseId', 'customerName']) const resolved = inspectOceanBookingIdentity('RUN-2', partial, { oceanBooking: { id: 91, customerId: 21, warehouseId: 41, customerName: '本轮客户', } }) assert.deepEqual(resolved.identity, { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户', }) }) test('truck appointment rejects mismatched booking detail instead of mixing identities', () => { const wrongResource = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2' }, { oceanBooking: { id: '92', customerId: '21', warehouseId: '41', customerName: '其他订舱' }, }) assert.equal(wrongResource.identity, null) assert.match(wrongResource.reason, /资源归属不一致/) const conflictingCustomer = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2', customerId: '21', }, { oceanBooking: { id: '91', customerId: '22', warehouseId: '41', customerName: '本轮客户' } }) assert.equal(conflictingCustomer.identity, null) assert.match(conflictingCustomer.reason, /customerId/) }) test('create edit and stale replay payloads keep the independent customer and empty links stable', () => { const context = { marker: 'RUN-2', createdResources: { truckSupplier: { supplierId: '501', supplierName: '正式拖车商' }, } } const identity = { customerId: '21', customerName: '本轮客户' } const payloads = [ truckAppointmentPayload(context, '2', identity), truckAppointmentPayload(context, '2', identity, 3), truckAppointmentPayload(context, '2', identity, 0), ] for (const payload of payloads) { assert.deepEqual({ oceanBookingId: payload.oceanBookingId, containerBookingId: payload.containerBookingId, loadingPlanId: payload.loadingPlanId, customerId: payload.customerId, customerName: payload.customerName, carrierId: payload.carrierId, }, { oceanBookingId: null, containerBookingId: null, loadingPlanId: null, customerId: '21', customerName: '本轮客户', carrierId: '501' }) } assert.deepEqual(payloads.map((payload) => payload.expectedVersion), [0, 3, 0]) }) test('truck payload requires a verified carrier ID/name pair', () => { const identity = { customerId: '21', customerName: '本轮客户' } const missing = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {} }, '2', identity) assert.equal(missing.carrierId, null) assert.equal(missing.carrierName, null) const valid = truckAppointmentPayload({ marker: 'RUN-2', createdResources: { truckSupplier: { supplierId: '501', supplierName: '正式拖车商' }, } }, '2', identity) assert.deepEqual({ carrierId: valid.carrierId, carrierName: valid.carrierName }, { carrierId: '501', carrierName: '正式拖车商' }) const incomplete = truckAppointmentPayload({ marker: 'RUN-2', createdResources: { truckSupplier: { supplierId: '501', supplierName: null }, } }, '2', identity) assert.equal(incomplete.carrierId, '501') assert.equal(incomplete.carrierName, null) }) test('independent customer comes only from the current marker fixture', () => { const current = selectIndependentTruckCustomer({ marker: 'RUN-2', createdResources: { orders: { marker: 'RUN-2', tenantId: '21', customerName: '本轮客户' }, } }) assert.deepEqual(current, { customerId: '21', customerName: '本轮客户' }) const stale = selectIndependentTruckCustomer({ marker: 'RUN-2', createdResources: { orders: { marker: 'RUN-1', tenantId: '20', customerName: '历史客户' }, } }) assert.equal(stale, null) }) test('truck appointment source has no booking prerequisite historical list or hardcoded identity fallback', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') assert.doesNotMatch(source, /customerId:\s*['"]10001['"]|warehouseId:\s*['"]193000000000000001['"]/) assert.doesNotMatch(source, /import\(new URL\('\.\/ocean-bookings\.mjs'/) assert.match(source, /创建无订舱柜次计划关联的独立拖车预约/) assert.match(source, /缺少同 marker 的 customerId;未借用历史客户或订舱/) }) test('backend freezes independent creation association validation and PDA failure isolation', () => { const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java', import.meta.url), 'utf8') assert.match(dto, /@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId/) assert.match(dto, /@NotNull @Positive Long customerId/) assert.match(dto, /@NotNull @Positive Long carrierId/) assert.match(dto, /@NotNull LocalDateTime plannedLoadingAt/) assert.doesNotMatch(dto, /@NotNull @Positive Long oceanBookingId|@NotNull @Positive Long containerBookingId/) const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8') assert.match(openapi, /required: \[customerId, carrierId, plannedLoadingAt, expectedVersion\]/) assert.match(openapi, /Ocean booking, container booking and loading plan are optional links/) assert.match(openapi, /absence, duplicates or concurrent truck edits never roll back loading completion/) const service = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', import.meta.url), 'utf8') for (const token of ['customers.active(r.customerId(),r.customerName())', 'suppliers.trucking(r.carrierId(),r.carrierName())', 'customerId与订舱客户不一致', 'customerId与柜次客户不一致', 'containerBookingId未分配至该订舱', 'loadingPlanId与柜次当前装柜计划不一致', 'Propagation.REQUIRES_NEW', 'active.isEmpty()', 'active.size()!=1', 'repo.completeLoadingFromPda']) assert.match(service, new RegExp(token.replace(/[().]/g, '\\$&'))) const loading = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java', import.meta.url), 'utf8') assert.match(loading, /completeTruckAfterCommit/) assert.match(loading, /afterCommit\(\) \{ completion\.run\(\); \}/) assert.match(loading, /catch \(RuntimeException exception\)/) assert.ok(loading.indexOf('loadingListService.publishCompletedEvent') < loading.indexOf('completeTruckAfterCommit'), 'PDA task/plan/inbound completion must precede best-effort truck synchronization') const truckTests = readFileSync(new URL('../../firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java', import.meta.url), 'utf8') for (const scenario of ['pdaCompletionDoesNotRequireTruckAppointment', 'pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory', 'pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation', 'pdaCompletionRejectsConcurrentTruckModificationWithoutHistory']) assert.match(truckTests, new RegExp(scenario)) const loadingTests = readFileSync(new URL('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java', import.meta.url), 'utf8') assert.match(loadingTests, /conflictingTruckAppointmentsDoNotBlockPdaCompletion/) }) test('Admin requires customer carrier and planned time but keeps booking and container optional', () => { const source = readFileSync(new URL('../../../apps/admin-web/src/modules/trucking/components/TruckAppointmentForm.vue', import.meta.url), 'utf8') assert.match(source, /customerId: \[\{ required: true/) assert.match(source, /carrierId: \[\{ required: true/) assert.match(source, /plannedLoadingAt: \[\{ required: true/) assert.match(source, /label="关联柜次(可选)"/) assert.match(source, /label="关联订舱(可选)"/) assert.doesNotMatch(source, /containerBookingId: \[\{ required: true|oceanBookingId: \[\{ required: true/) }) test('default HTTP entry carries independent truck positives and association negatives after current-marker producers', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') for (const label of ['缺少 customerId 拒绝创建独立拖车', '缺少 ACTIVE TRUCKING carrier 拒绝创建独立拖车', '缺少 plannedLoadingAt 拒绝创建独立拖车', 'loadingPlanId 与关联柜次当前计划不一致时零写入拒绝', 'warehouseId 与可选关联柜次不一致时零写入拒绝', '订舱与柜次无 allocation 关系时零写入拒绝', '创建无订舱柜次计划关联的独立拖车预约']) assert.match(source, new RegExp(label)) const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') assert.match(entry, /'orders'[\s\S]*'phase3-loading'[\s\S]*'ocean-bookings'[\s\S]*'truck-appointments'/) }) test('dispatch requires bill of lading number and active BILL_OF_LADING attachment', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') assert.match(source, /attachmentType=BILL_OF_LADING/) assert.match(source, /billOfLadingNo:/) assert.match(source, /DISPATCH 缺提单号与文件明确拒绝/) assert.match(source, /派车前保留有效 BILL_OF_LADING 提单附件/) const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8') assert.match(openapi, /DISPATCH requires a nonblank billOfLadingNo/) assert.match(openapi, /BILL_OF_LADING attachment/) })