refactor(firstleg): remove customer scope from trucking

This commit is contained in:
2026-09-01 09:51:59 +08:00
parent 936412f351
commit 249b182e7f
17 changed files with 357 additions and 463 deletions
@@ -2,167 +2,71 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { inspectOceanBookingIdentity, selectIndependentTruckCustomer, truckAppointmentPayload } from './truck-appointments.mjs'
import { inspectOceanBookingIdentity, 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('booking identity is marker scoped and contains no customer snapshot', () => {
const result = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2', 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)
})
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('payload requires booking container and carrier but never emits customer fields', () => {
const context = { createdResources: { truckSupplier: { supplierId: '501', supplierName: '正式拖车商' } } }
const payload = truckAppointmentPayload(context, '2', {
oceanBookingId: '91', containerBookingId: '81', loadingPlanId: '71', 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)
})
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),
test('backend DTO entity mapper service and OpenAPI have no truck customer 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 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' })
for (const path of paths) {
const source = readFileSync(new URL(path, import.meta.url), 'utf8')
assert.doesNotMatch(source, /customerId|customerName|customer_id|customer_name/)
}
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', () => {
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')
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 oceanBookingId/)
assert.match(dto, /@NotNull @Positive Long containerBookingId/)
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/)
for (const token of ['"RELEASED"', 'containerBookingId未分配至该订舱', 'Iso6346ContainerNumber.require',
'oceanBookingDocuments.requireActiveValidDocument', 'suppliers.trucking', 'uk_truck_active_container']) {
assert.ok(service.includes(token), `missing ${token}`)
}
})
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', () => {
test('HTTP regression covers no-customer response and submit idempotency matrix', () => {
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'/)
for (const label of ['响应不含客户字段且冻结订舱柜号关系', '同键同载荷重放', '提交幂等重放零漂移',
'同键异载荷拒绝', '提单号与订舱不一致拒绝创建']) assert.match(source, new RegExp(label))
assert.doesNotMatch(source, /customerId\s*:|customerName\s*:|独立拖车/)
})
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/)
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}'\\)`))
}
})